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 <MayanRestoreView 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 <h2 class="peach-restore-card-headline">Mayan code</h2>463 <hr class="peach-restore-card-divider" />464 <p class="peach-restore-card-body">465 Restore from a printed Mayan code — scan or upload a photo.466 </p>467 <p class="peach-restore-card-meta">decoder shipping soon</p>468 </button>469470 <button471 class="peach-restore-card"472 role="listitem"473 onClick={props.onPickCSV}474 >475 <h2 class="peach-restore-card-headline">CSV</h2>476 <hr class="peach-restore-card-divider" />477 <p class="peach-restore-card-body">478 Move in from Bitwarden, 1Password, Proton Pass, or a browser479 (Chrome, Safari, Edge) CSV export.480 </p>481 <p class="peach-restore-card-meta">plain-text — open the file once and delete it</p>482 </button>483 </div>484485 </section>486 );487}488489490491492493494495496async function handleMayanDecode(_base64: string): Promise<497 { ok: true; payload: Uint8Array } | { ok: false; reason: string }498> {499 500 return { ok: false, reason: "Mayan decoder is not yet shipped." };501}502503function MayanRestoreView(props: { onBack: () => void }) {504 const [decodeError, setDecodeError] = createSignal<string | null>(null);505 const [decoding, setDecoding] = createSignal(false);506 const isAndroid = createMemo(() =>507 Capacitor.isNativePlatform() && Capacitor.getPlatform() === "android",508 );509510 async function processBase64(base64: string) {511 setDecoding(true);512 setDecodeError(null);513 try {514 const result = await handleMayanDecode(base64);515 if (!result.ok) {516 setDecodeError(result.reason);517 return;518 }519 520 setDecodeError("Decoded — but the restore flow isn't wired yet.");521 } finally {522 setDecoding(false);523 }524 }525526 async function handleCameraScan() {527 setDecodeError(null);528 try {529 const { Camera, CameraResultType, CameraSource } = await import("@capacitor/camera");530 const image = await Camera.getPhoto({531 quality: 90,532 resultType: CameraResultType.Base64,533 source: CameraSource.Camera,534 allowEditing: false,535 });536 if (image.base64String) await processBase64(image.base64String);537 } catch (err) {538 539 const msg = err instanceof Error ? err.message : "";540 if (msg && !/cancel/i.test(msg)) setDecodeError(msg);541 }542 }543544 function handleFileUpload(e: Event) {545 const input = e.currentTarget as HTMLInputElement;546 const file = input.files?.[0];547 if (!file) return;548 const reader = new FileReader();549 reader.onload = () => {550 const result = reader.result;551 if (typeof result !== "string") return;552 const base64 = result.split(",", 2)[1] ?? "";553 void processBase64(base64);554 };555 reader.readAsDataURL(file);556 }557558 return (559 <section560 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"561 data-motion-phase="enter"562 >563 <header class="peach-screen-header lock-screen-header">564 <PeachWordmark class="peach-wordmark-wrap" />565 <button class="peach-lock-restore-link" onClick={props.onBack}>566 ‹ back567 </button>568 </header>569570 <div class="peach-lock-hero">571 <span class="peach-lock-eyebrow">mayan code</span>572 <h1 class="peach-onboarding-headline">scan or upload.</h1>573 </div>574575 <div class="peach-restore-stack">576 <Show when={isAndroid()}>577 <button578 class="peach-restore-card"579 disabled={decoding()}580 onClick={() => void handleCameraScan()}581 >582 <h2 class="peach-restore-card-headline">Scan with camera</h2>583 <hr class="peach-restore-card-divider" />584 <p class="peach-restore-card-body">585 Point your camera at the printed Mayan code.586 </p>587 </button>588 </Show>589590 <label591 class="peach-restore-card"592 classList={{ "peach-restore-card--disabled": decoding() }}593 >594 <h2 class="peach-restore-card-headline">Choose from gallery</h2>595 <hr class="peach-restore-card-divider" />596 <p class="peach-restore-card-body">597 Pick a saved photo of your Mayan code.598 </p>599 <input600 type="file"601 accept="image/*"602 style="display:none"603 onChange={handleFileUpload}604 disabled={decoding()}605 />606 </label>607608 <Show when={decodeError()}>609 <div class="peach-restore-card peach-restore-card--static">610 <p class="peach-restore-card-body">{decodeError()}</p>611 <p class="peach-restore-card-meta">612 Until the decoder lands, restore via PeachScript or a <code>.peach</code> file.613 </p>614 </div>615 </Show>616 </div>617618 <div class="peach-restore-footer">619 <button class="peach-restore-footer-link" onClick={props.onBack}>620 ‹ back to restore621 </button>622 </div>623 </section>624 );625}626627628629async function handleBiometricUnlock(props: { onUnlock: () => void | Promise<void> }) {630 const unlocked = await biometricUnlock();631 if (unlocked) {632 await props.onUnlock();633 }634}635636function BiometricUnlockButton(props: { ready: boolean; loading: () => boolean; onUnlock: () => void | Promise<void> }) {637 const [bioAvailable, setBioAvailable] = createSignal(false);638 onMount(async () => {639 setBioAvailable(await biometricAvailable());640 });641 return (642 <Show when={bioAvailable()}>643 <button644 class="peach-btn peach-btn--subtle peach-btn--md peach-biometric-btn"645 onClick={() => void handleBiometricUnlock(props)}646 disabled={!props.ready || props.loading()}647 type="button"648 >649 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">650 <path d="M8 1a3 3 0 0 0-3 3v4a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />651 <path d="M5 8v1a3 3 0 0 0 6 0V8" />652 <line x1="8" y1="12" x2="8" y2="15" />653 <line x1="5" y1="15" x2="11" y2="15" />654 </svg>655 use biometrics656 </button>657 </Show>658 );659}660661662663function getDisplayError(error: string | undefined, fallback: string): string {664 if (!error) return fallback;665 if (666 error === "SERVICE_WORKER_UNAVAILABLE" ||667 error.includes("Receiving end does not exist") ||668 error.includes("message port closed")669 ) {670 return "background service unavailable";671 }672 return error;673}