1import { Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js";2import { Capacitor } from "@capacitor/core";34import { sendMessage } from "../App";5import { LockRuneCanvas } from "../components/LockRuneCanvas";6import { PeachMark, PeachWordmark } from "../components/PeachMark";7import { ImportView } from "./Import";8import { biometricUnlock, biometricAvailable } from "../../shared/biometric";91011let _zxcvbn: ((password: string) => { score: number }) | null = null;12async function getZxcvbn() {13 if (_zxcvbn) return _zxcvbn;14 const mod = await import("zxcvbn");15 _zxcvbn = mod.default;16 return _zxcvbn;17}1819interface LockProps {20 hasVault: boolean;21 ready: boolean;22 isPro?: boolean;23 onUnlock: () => void | Promise<void>;24 252627 onBackToOnboarding?: () => void;28}29303132type RestoreSurface = "restore" | "peachscript" | "file" | "mayan" | null;3334export function LockView(props: LockProps) {35 const [password, setPassword] = createSignal("");36 const [confirmPassword, setConfirmPassword] = createSignal("");37 const [error, setError] = createSignal("");38 const [loading, setLoading] = createSignal(false);39 const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);40 const [showPassword, setShowPassword] = createSignal(false);4142 const MIN_LENGTH = 12;43 const MIN_ZXCVBN_SCORE = 2;4445 const [strengthScore, setStrengthScore] = createSignal<number | null>(null);4647 const canCreate = createMemo(48 () =>49 password().length >= MIN_LENGTH &&50 confirmPassword().length >= MIN_LENGTH &&51 password() === confirmPassword() &&52 strengthScore() !== null &&53 strengthScore()! >= MIN_ZXCVBN_SCORE54 );5556 const passwordStrength = createMemo(() => {57 const score = strengthScore();58 if (score === null) return null;59 if (score <= 1) return "weak";60 if (score === 2) return "fair";61 if (score === 3) return "good";62 return "strong";63 });6465 const createBlockerMessage = createMemo(() => {66 if (!password()) return null;67 if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;68 if (!confirmPassword()) return "confirm your password";69 if (password() !== confirmPassword()) return "passwords don't match";70 if (strengthScore() === null) return "checking strength…";71 if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";72 return null;73 });7475 76 createMemo(() => {77 const value = password();78 if (!value) { setStrengthScore(null); return; }79 getZxcvbn().then((zxcvbn) => {80 if (password() === value) setStrengthScore(zxcvbn(value).score);81 }).catch(() => {82 83 if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);84 });85 });8687 async function handleUnlock() {88 if (!password().trim()) return;89 setLoading(true);90 setError("");91 const response = await sendMessage({92 type: "UNLOCK",93 payload: { password: password() },94 });95 const result = response as { ok: boolean; error?: string };96 if (result.ok) {97 await props.onUnlock();98 setLoading(false);99 return;100 }101 setError(getDisplayError(result.error, "incorrect password"));102 setPassword("");103 const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;104 passwordField?.classList.add("shake");105 setTimeout(() => passwordField?.classList.remove("shake"), 300);106 setLoading(false);107 }108109 async function handleCreate() {110 if (!password().trim()) return;111 if (password().length < MIN_LENGTH) {112 setError(`password must be at least ${MIN_LENGTH} characters`);113 return;114 }115 if (password() !== confirmPassword()) {116 setError("passwords do not match");117 return;118 }119 const score = strengthScore();120 if (score === null || score < MIN_ZXCVBN_SCORE) {121 setError("password is too weak — try a longer or more varied passphrase");122 return;123 }124 setLoading(true);125 setError("");126 const response = await sendMessage({127 type: "CREATE_VAULT",128 payload: { password: password() },129 });130 const result = response as { ok: boolean; error?: string };131 if (result.ok) {132 await props.onUnlock();133 setLoading(false);134 return;135 }136 setError(getDisplayError(result.error, "failed to create vault"));137 setLoading(false);138 }139140 function openRestore(surface: Exclude<RestoreSurface, null>) {141 if (!props.ready || loading()) return;142 setPassword("");143 setConfirmPassword("");144 setError("");145 setRestoreSurface(surface);146 }147148 149 return (150 <Switch>151 {}152 <Match when={restoreSurface() === "restore"}>153 <RestorePickerView154 onBack={() => setRestoreSurface(null)}155 onPickPeachScript={() => setRestoreSurface("peachscript")}156 onPickMayan={() => setRestoreSurface("mayan")}157 onPickCSV={() => setRestoreSurface("file")}158 />159 </Match>160161 {}162 <Match when={restoreSurface() === "mayan"}>163 <MayanComingSoonView onBack={() => setRestoreSurface("restore")} />164 </Match>165166 {}167 <Match when={restoreSurface() === "peachscript"}>168 <ImportView169 mode="onboarding"170 replacesExistingVault={props.hasVault}171 initialView="peachscript"172 onBack={() => setRestoreSurface("restore")}173 onImport={async () => {174 setRestoreSurface(null);175 await props.onUnlock();176 }}177 />178 </Match>179180 {}181 <Match when={restoreSurface() === "file"}>182 <ImportView183 mode="onboarding"184 replacesExistingVault={props.hasVault}185 initialView="file"186 onBack={() => setRestoreSurface("restore")}187 onImport={async () => {188 setRestoreSurface(null);189 await props.onUnlock();190 }}191 />192 </Match>193194 {}195 <Match when={props.hasVault}>196 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">197 <header class="peach-screen-header lock-screen-header">198 <div class="peach-wordmark-stack">199 <PeachWordmark class="peach-wordmark-wrap" />200 <Show when={props.isPro}>201 <span class="peach-pro-mini-badge">pro</span>202 </Show>203 </div>204 </header>205206 <div class="peach-lock-hero peach-lock-hero--unlock">207 <div class="peach-lock-hero-art" aria-hidden="true">208 <PeachMark class="peach-lock-hero-mark" />209 </div>210 <span class="peach-lock-eyebrow">vault locked</span>211 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">212 unlock213 <br />214 your vault.215 </h1>216 </div>217218 <LockRuneCanvas />219220 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">221 <label class="peach-label" for="unlock-password">222 master password223 </label>224 <input225 id="unlock-password"226 class="peach-underline-input peach-unlock-input lock-password-input"227 type="password"228 placeholder="••••••••••••"229 autocomplete="current-password"230 value={password()}231 disabled={!props.ready}232 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}233 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}234 onKeyDown={(event) => {235 if (event.key !== "Enter" || !props.ready) return;236 void handleUnlock();237 }}238 />239240 <Show when={error()}>241 <p class="peach-hint peach-hint-error">{error()}</p>242 </Show>243244 <button245 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"246 onClick={() => void handleUnlock()}247 disabled={!props.ready || loading()}248 >249 {loading() ? "unlocking..." : "unlock"}250 </button>251252 <Show when={Capacitor.isNativePlatform()}>253 <BiometricUnlockButton ready={props.ready} loading={loading} onUnlock={props.onUnlock} />254 </Show>255 </section>256 </section>257 </Match>258259 {}260 <Match when={true}>261 <section262 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"263 data-motion-phase="enter"264 >265 <header class="peach-screen-header peach-newvault-header">266 <Show when={props.onBackToOnboarding}>267 <button268 class="peach-newvault-back"269 type="button"270 onClick={() => props.onBackToOnboarding?.()}271 aria-label="Back to onboarding"272 >273 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">274 <path d="M7 2L3 5.5L7 9" />275 </svg>276 back277 </button>278 </Show>279 <div class="peach-wordmark-stack">280 <PeachWordmark class="peach-wordmark-wrap" />281 <Show when={props.isPro}>282 <span class="peach-pro-mini-badge">pro</span>283 </Show>284 </div>285 </header>286287 <div class="peach-newvault-mark" aria-hidden="true">288 <PeachMark class="peach-newvault-mark-art" />289 </div>290291 <div class="peach-newvault-card">292 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>293 <hr class="peach-newvault-divider" />294295 <div class="peach-newvault-field">296 <div class="peach-newvault-label-row">297 <label class="peach-label" for="master-password">298 master password299 </label>300 <button301 class="peach-lock-reveal-btn"302 type="button"303 onClick={() => setShowPassword((value) => !value)}304 aria-label={showPassword() ? "Hide password" : "Show password"}305 >306 <Show307 when={showPassword()}308 fallback={309 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">310 <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" />311 <circle cx="7" cy="5" r="1.75" />312 </svg>313 }314 >315 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">316 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />317 <path d="M3.5 5.5L2.5 7.5" />318 <path d="M7 6.5V8.5" />319 <path d="M10.5 5.5L11.5 7.5" />320 </svg>321 </Show>322 {showPassword() ? "hide" : "show"}323 </button>324 </div>325 <input326 id="master-password"327 class="peach-underline-input peach-newvault-input lock-password-input"328 type={showPassword() ? "text" : "password"}329 placeholder="••••••••••••"330 autocomplete="new-password"331 value={password()}332 disabled={!props.ready}333 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}334 onKeyDown={(event) => {335 if (event.key !== "Enter" || !props.ready) return;336 void handleCreate();337 }}338 />339 <Show340 when={password().length > 0 && passwordStrength() !== null}341 fallback={342 <p class="peach-newvault-hint">at least 12 characters</p>343 }344 >345 <div class="peach-password-strength">346 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>347 <div class="peach-strength-fill"></div>348 </div>349 <span class="peach-strength-label">{passwordStrength()}</span>350 </div>351 </Show>352 </div>353354 <div class="peach-newvault-field">355 <label class="peach-label" for="confirm-password">356 confirm password357 </label>358 <input359 id="confirm-password"360 class="peach-underline-input peach-newvault-input"361 type={showPassword() ? "text" : "password"}362 placeholder="••••••••••••"363 autocomplete="new-password"364 value={confirmPassword()}365 disabled={!props.ready}366 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}367 onKeyDown={(event) => {368 if (event.key === "Enter" && props.ready) void handleCreate();369 }}370 />371 </div>372373 <Show when={error()}>374 <p class="peach-hint peach-hint-error">{error()}</p>375 </Show>376377 <button378 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"379 onClick={() => void handleCreate()}380 disabled={!props.ready || loading() || !canCreate()}381 aria-live="polite"382 >383 <Show384 when={!canCreate() && createBlockerMessage()}385 fallback={loading() ? "creating..." : "create vault"}386 >387 {createBlockerMessage()}388 </Show>389 </button>390391 <p class="peach-newvault-warning">392 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>393 <span>If you forget it, your vault cannot be opened. There is no reset.</span>394 </p>395 </div>396397 <div class="peach-newvault-footer">398 <p class="peach-hint">Already have a backup?</p>399 <button400 class="peach-lock-restore-link"401 onClick={() => openRestore("restore")}402 disabled={!props.ready}403 >404 › restore from backup405 </button>406 </div>407 </section>408 </Match>409 </Switch>410 );411}412413414415function RestorePickerView(props: {416 onBack: () => void;417 onPickPeachScript: () => void;418 onPickMayan: () => void;419 onPickCSV: () => void;420}) {421 return (422 <section423 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"424 data-motion-phase="enter"425 >426 <header class="peach-screen-header lock-screen-header">427 <PeachWordmark class="peach-wordmark-wrap" />428 <button class="peach-lock-restore-link" onClick={props.onBack}>429 ‹ back430 </button>431 </header>432433 <div class="peach-lock-hero">434 <span class="peach-lock-eyebrow">restore</span>435 <h1 class="peach-onboarding-headline">436 bring back437 <br />438 your vault.439 </h1>440 </div>441442 <div class="peach-restore-stack" role="list">443 <button444 class="peach-restore-card"445 role="listitem"446 onClick={props.onPickPeachScript}447 >448 <h2 class="peach-restore-card-headline">PeachScript</h2>449 <hr class="peach-restore-card-divider" />450 <p class="peach-restore-card-body">451 Paste a string of Peach symbols, or open a <code>.peach</code> file452 you saved from another device.453 </p>454 <p class="peach-restore-card-meta">backup password required</p>455 </button>456457 <button458 class="peach-restore-card"459 role="listitem"460 onClick={props.onPickMayan}461 >462 <span class="peach-restore-card-soon" aria-label="coming soon">soon</span>463 <h2 class="peach-restore-card-headline">Mayan code</h2>464 <hr class="peach-restore-card-divider" />465 <p class="peach-restore-card-body">466 Restore from a printed Mayan code by uploading a photo of it.467 </p>468 <p class="peach-restore-card-meta">decoder shipping soon</p>469 </button>470471 <button472 class="peach-restore-card"473 role="listitem"474 onClick={props.onPickCSV}475 >476 <h2 class="peach-restore-card-headline">CSV</h2>477 <hr class="peach-restore-card-divider" />478 <p class="peach-restore-card-body">479 Move in from Bitwarden, 1Password, Proton Pass, or a browser480 (Chrome, Safari, Edge) CSV export.481 </p>482 <p class="peach-restore-card-meta">plain-text — open the file once and delete it</p>483 </button>484 </div>485486 </section>487 );488}489490491492function MayanComingSoonView(props: { onBack: () => void }) {493 return (494 <section495 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"496 data-motion-phase="enter"497 >498 <header class="peach-screen-header lock-screen-header">499 <PeachWordmark class="peach-wordmark-wrap" />500 <button class="peach-lock-restore-link" onClick={props.onBack}>501 ‹ back502 </button>503 </header>504505 <div class="peach-lock-hero">506 <span class="peach-lock-eyebrow">mayan code</span>507 <h1 class="peach-onboarding-headline">508 decoder509 <br />510 on its way.511 </h1>512 </div>513514 <div class="peach-restore-stack">515 <div class="peach-restore-card peach-restore-card--static">516 <h2 class="peach-restore-card-headline">Not yet — but soon.</h2>517 <hr class="peach-restore-card-divider" />518 <p class="peach-restore-card-body">519 Mayan codes encode the same backup as PeachScript, but printable on520 a single page. The phone-camera decoder is the next thing to land521 for this format.522 </p>523 <p class="peach-restore-card-body">524 Until then, restore your vault using the PeachScript that came525 with this Mayan code, or open a <code>.peach</code> file backup.526 </p>527 </div>528 </div>529530 <div class="peach-restore-footer">531 <button class="peach-restore-footer-link" onClick={props.onBack}>532 ‹ back to restore533 </button>534 </div>535 </section>536 );537}538539540541async function handleBiometricUnlock(props: { onUnlock: () => void | Promise<void> }) {542 const unlocked = await biometricUnlock();543 if (unlocked) {544 await props.onUnlock();545 }546}547548function BiometricUnlockButton(props: { ready: boolean; loading: () => boolean; onUnlock: () => void | Promise<void> }) {549 const [bioAvailable, setBioAvailable] = createSignal(false);550 onMount(async () => {551 setBioAvailable(await biometricAvailable());552 });553 return (554 <Show when={bioAvailable()}>555 <button556 class="peach-btn peach-btn--subtle peach-btn--md peach-biometric-btn"557 onClick={() => void handleBiometricUnlock(props)}558 disabled={!props.ready || props.loading()}559 type="button"560 >561 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">562 <path d="M8 1a3 3 0 0 0-3 3v4a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />563 <path d="M5 8v1a3 3 0 0 0 6 0V8" />564 <line x1="8" y1="12" x2="8" y2="15" />565 <line x1="5" y1="15" x2="11" y2="15" />566 </svg>567 use biometrics568 </button>569 </Show>570 );571}572573574575function getDisplayError(error: string | undefined, fallback: string): string {576 if (!error) return fallback;577 if (578 error === "SERVICE_WORKER_UNAVAILABLE" ||579 error.includes("Receiving end does not exist") ||580 error.includes("message port closed")581 ) {582 return "background service unavailable";583 }584 return error;585}