1import { For, 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" | "snapshots" | "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 onPickSnapshots={() => setRestoreSurface("snapshots")}159 />160 </Match>161162 {}163 <Match when={restoreSurface() === "mayan"}>164 <MayanComingSoonView onBack={() => setRestoreSurface("restore")} />165 </Match>166167 {}168 <Match when={restoreSurface() === "snapshots"}>169 <SnapshotRestoreView170 onBack={() => setRestoreSurface("restore")}171 onRestored={props.onUnlock}172 />173 </Match>174175 {}176 <Match when={restoreSurface() === "peachscript"}>177 <ImportView178 mode="onboarding"179 replacesExistingVault={props.hasVault}180 initialView="peachscript"181 onBack={() => setRestoreSurface("restore")}182 onImport={async () => {183 setRestoreSurface(null);184 await props.onUnlock();185 }}186 />187 </Match>188189 {}190 <Match when={restoreSurface() === "file"}>191 <ImportView192 mode="onboarding"193 replacesExistingVault={props.hasVault}194 initialView="file"195 onBack={() => setRestoreSurface("restore")}196 onImport={async () => {197 setRestoreSurface(null);198 await props.onUnlock();199 }}200 />201 </Match>202203 {}204 <Match when={props.hasVault}>205 <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">206 <header class="peach-screen-header lock-screen-header">207 <div class="peach-wordmark-stack">208 <PeachWordmark class="peach-wordmark-wrap" />209 <Show when={props.isPro}>210 <span class="peach-pro-mini-badge">pro</span>211 </Show>212 </div>213 </header>214215 <div class="peach-lock-hero peach-lock-hero--unlock">216 <div class="peach-lock-hero-art" aria-hidden="true">217 <PeachMark class="peach-lock-hero-mark" />218 </div>219 <span class="peach-lock-eyebrow">vault locked</span>220 <h1 class="peach-onboarding-headline peach-lock-headline--unlock">221 unlock222 <br />223 your vault.224 </h1>225 </div>226227 <LockRuneCanvas />228229 <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">230 <label class="peach-label" for="unlock-password">231 master password232 </label>233 <input234 id="unlock-password"235 class="peach-underline-input peach-unlock-input lock-password-input"236 type="password"237 placeholder="••••••••••••"238 autocomplete="current-password"239 value={password()}240 disabled={!props.ready}241 ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}242 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}243 onKeyDown={(event) => {244 if (event.key !== "Enter" || !props.ready) return;245 void handleUnlock();246 }}247 />248249 <Show when={error()}>250 <p class="peach-hint peach-hint-error">{error()}</p>251 </Show>252253 <button254 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"255 onClick={() => void handleUnlock()}256 disabled={!props.ready || loading()}257 >258 {loading() ? "unlocking..." : "unlock"}259 </button>260261 <Show when={Capacitor.isNativePlatform()}>262 <BiometricUnlockButton ready={props.ready} loading={loading} onUnlock={props.onUnlock} />263 </Show>264 </section>265 </section>266 </Match>267268 {}269 <Match when={true}>270 <section271 class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"272 data-motion-phase="enter"273 >274 <header class="peach-screen-header peach-newvault-header">275 <Show when={props.onBackToOnboarding}>276 <button277 class="peach-newvault-back"278 type="button"279 onClick={() => props.onBackToOnboarding?.()}280 aria-label="Back to onboarding"281 >282 <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">283 <path d="M7 2L3 5.5L7 9" />284 </svg>285 back286 </button>287 </Show>288 <div class="peach-wordmark-stack">289 <PeachWordmark class="peach-wordmark-wrap" />290 <Show when={props.isPro}>291 <span class="peach-pro-mini-badge">pro</span>292 </Show>293 </div>294 </header>295296 <div class="peach-newvault-mark" aria-hidden="true">297 <PeachMark class="peach-newvault-mark-art" />298 </div>299300 <div class="peach-newvault-card">301 <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>302 <hr class="peach-newvault-divider" />303304 <div class="peach-newvault-field">305 <div class="peach-newvault-label-row">306 <label class="peach-label" for="master-password">307 master password308 </label>309 <button310 class="peach-lock-reveal-btn"311 type="button"312 onClick={() => setShowPassword((value) => !value)}313 aria-label={showPassword() ? "Hide password" : "Show password"}314 >315 <Show316 when={showPassword()}317 fallback={318 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">319 <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" />320 <circle cx="7" cy="5" r="1.75" />321 </svg>322 }323 >324 <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">325 <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />326 <path d="M3.5 5.5L2.5 7.5" />327 <path d="M7 6.5V8.5" />328 <path d="M10.5 5.5L11.5 7.5" />329 </svg>330 </Show>331 {showPassword() ? "hide" : "show"}332 </button>333 </div>334 <input335 id="master-password"336 class="peach-underline-input peach-newvault-input lock-password-input"337 type={showPassword() ? "text" : "password"}338 placeholder="••••••••••••"339 autocomplete="new-password"340 value={password()}341 disabled={!props.ready}342 onInput={(event) => setPassword((event.target as HTMLInputElement).value)}343 onKeyDown={(event) => {344 if (event.key !== "Enter" || !props.ready) return;345 void handleCreate();346 }}347 />348 <Show349 when={password().length > 0 && passwordStrength() !== null}350 fallback={351 <p class="peach-newvault-hint">at least 12 characters</p>352 }353 >354 <div class="peach-password-strength">355 <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>356 <div class="peach-strength-fill"></div>357 </div>358 <span class="peach-strength-label">{passwordStrength()}</span>359 </div>360 </Show>361 </div>362363 <div class="peach-newvault-field">364 <label class="peach-label" for="confirm-password">365 confirm password366 </label>367 <input368 id="confirm-password"369 class="peach-underline-input peach-newvault-input"370 type={showPassword() ? "text" : "password"}371 placeholder="••••••••••••"372 autocomplete="new-password"373 value={confirmPassword()}374 disabled={!props.ready}375 onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}376 onKeyDown={(event) => {377 if (event.key === "Enter" && props.ready) void handleCreate();378 }}379 />380 </div>381382 <Show when={error()}>383 <p class="peach-hint peach-hint-error">{error()}</p>384 </Show>385386 <button387 class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"388 onClick={() => void handleCreate()}389 disabled={!props.ready || loading() || !canCreate()}390 aria-live="polite"391 >392 <Show393 when={!canCreate() && createBlockerMessage()}394 fallback={loading() ? "creating..." : "create vault"}395 >396 {createBlockerMessage()}397 </Show>398 </button>399400 <p class="peach-newvault-warning">401 <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>402 <span>If you forget it, your vault cannot be opened. There is no reset.</span>403 </p>404 </div>405406 <div class="peach-newvault-footer">407 <p class="peach-hint">Already have a backup?</p>408 <button409 class="peach-lock-restore-link"410 onClick={() => openRestore("restore")}411 disabled={!props.ready}412 >413 › restore from backup414 </button>415 </div>416 </section>417 </Match>418 </Switch>419 );420}421422423424function RestorePickerView(props: {425 onBack: () => void;426 onPickPeachScript: () => void;427 onPickMayan: () => void;428 onPickCSV: () => void;429 onPickSnapshots: () => void;430}) {431 return (432 <section433 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"434 data-motion-phase="enter"435 >436 <header class="peach-screen-header lock-screen-header">437 <PeachWordmark class="peach-wordmark-wrap" />438 <button class="peach-lock-restore-link" onClick={props.onBack}>439 ‹ back440 </button>441 </header>442443 <div class="peach-lock-hero">444 <span class="peach-lock-eyebrow">restore</span>445 <h1 class="peach-onboarding-headline">446 bring back447 <br />448 your vault.449 </h1>450 </div>451452 <div class="peach-restore-stack" role="list">453 <button454 class="peach-restore-card"455 role="listitem"456 onClick={props.onPickPeachScript}457 >458 <h2 class="peach-restore-card-headline">PeachScript</h2>459 <hr class="peach-restore-card-divider" />460 <p class="peach-restore-card-body">461 Paste a string of Peach symbols, or open a <code>.peach</code> file462 you saved from another device.463 </p>464 <p class="peach-restore-card-meta">backup password required</p>465 </button>466467 <button468 class="peach-restore-card"469 role="listitem"470 onClick={props.onPickMayan}471 >472 <span class="peach-restore-card-soon" aria-label="coming soon">soon</span>473 <h2 class="peach-restore-card-headline">Mayan code</h2>474 <hr class="peach-restore-card-divider" />475 <p class="peach-restore-card-body">476 Restore from a printed Mayan code by uploading a photo of it.477 </p>478 <p class="peach-restore-card-meta">decoder shipping soon</p>479 </button>480481 <button482 class="peach-restore-card"483 role="listitem"484 onClick={props.onPickCSV}485 >486 <h2 class="peach-restore-card-headline">CSV</h2>487 <hr class="peach-restore-card-divider" />488 <p class="peach-restore-card-body">489 Move in from Bitwarden, 1Password, Proton Pass, or a browser490 (Chrome, Safari, Edge) CSV export.491 </p>492 <p class="peach-restore-card-meta">plain-text — open the file once and delete it</p>493 </button>494 </div>495496 <div class="peach-restore-footer">497 <button498 class="peach-restore-footer-link"499 onClick={props.onPickSnapshots}500 >501 or revert to a local snapshot →502 </button>503 </div>504 </section>505 );506}507508509510function MayanComingSoonView(props: { onBack: () => void }) {511 return (512 <section513 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"514 data-motion-phase="enter"515 >516 <header class="peach-screen-header lock-screen-header">517 <PeachWordmark class="peach-wordmark-wrap" />518 <button class="peach-lock-restore-link" onClick={props.onBack}>519 ‹ back520 </button>521 </header>522523 <div class="peach-lock-hero">524 <span class="peach-lock-eyebrow">mayan code</span>525 <h1 class="peach-onboarding-headline">526 decoder527 <br />528 on its way.529 </h1>530 </div>531532 <div class="peach-restore-stack">533 <div class="peach-restore-card peach-restore-card--static">534 <h2 class="peach-restore-card-headline">Not yet — but soon.</h2>535 <hr class="peach-restore-card-divider" />536 <p class="peach-restore-card-body">537 Mayan codes encode the same backup as PeachScript, but printable on538 a single page. The phone-camera decoder is the next thing to land539 for this format.540 </p>541 <p class="peach-restore-card-body">542 Until then, restore your vault using the PeachScript that came543 with this Mayan code, or open a <code>.peach</code> file backup.544 </p>545 </div>546 </div>547548 <div class="peach-restore-footer">549 <button class="peach-restore-footer-link" onClick={props.onBack}>550 ‹ back to restore551 </button>552 </div>553 </section>554 );555}556557558559interface SnapshotSummary {560 index: number;561 timestamp: number;562 entryCount: number;563}564565function SnapshotRestoreView(props: {566 onBack: () => void;567 onRestored: () => void | Promise<void>;568}) {569 const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);570 const [loading, setLoading] = createSignal(true);571 const [selected, setSelected] = createSignal<number | null>(null);572 const [password, setPassword] = createSignal("");573 const [confirming, setConfirming] = createSignal(false);574 const [restoring, setRestoring] = createSignal(false);575 const [error, setError] = createSignal("");576577 onMount(async () => {578 const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });579 setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);580 setLoading(false);581 });582583 function formatAge(ts: number): string {584 const diff = Date.now() - ts;585 const mins = Math.floor(diff / 60_000);586 if (mins < 2) return "just now";587 if (mins < 60) return `${mins} minutes ago`;588 const hours = Math.floor(mins / 60);589 if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;590 const days = Math.floor(hours / 24);591 return `${days} day${days === 1 ? "" : "s"} ago`;592 }593594 function selectSnapshot(index: number) {595 setSelected(index);596 setConfirming(false);597 setPassword("");598 setError("");599 }600601 async function handleRestore() {602 const idx = selected();603 if (idx === null || !password()) return;604 setRestoring(true);605 setError("");606 const res = await sendMessage({607 type: "RESTORE_VAULT_SNAPSHOT",608 payload: { index: idx, password: password() },609 });610 if ((res as { ok: boolean }).ok) {611 await props.onRestored();612 } else {613 setError("Incorrect password, or this snapshot cannot be decrypted.");614 setRestoring(false);615 }616 }617618 const selectedSnap = () => {619 const idx = selected();620 return idx !== null ? (snapshots()[idx] ?? null) : null;621 };622623 return (624 <section625 class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"626 data-motion-phase="enter"627 >628 <header class="peach-screen-header lock-screen-header">629 <PeachWordmark class="peach-wordmark-wrap" />630 <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>631 ‹ back632 </button>633 </header>634635 <div class="peach-lock-hero">636 <span class="peach-lock-eyebrow">vault history</span>637 <h1 class="peach-onboarding-headline">638 revert to639 <br />640 a snapshot.641 </h1>642 </div>643644 <section class="peach-lock-panel peach-snapshot-panel">645 <Show when={loading()}>646 <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>647 </Show>648649 <Show when={!loading() && snapshots().length === 0}>650 <p class="peach-hint" style="line-height:1.6;">651 No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.652 </p>653 </Show>654655 <Show when={!loading() && snapshots().length > 0}>656 <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>657 <div class="peach-snapshot-list">658 <For each={snapshots()}>659 {(snap) => (660 <button661 class="peach-snapshot-row"662 classList={{ "is-selected": selected() === snap.index }}663 onClick={() => selectSnapshot(snap.index)}664 disabled={restoring()}665 >666 <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>667 <span class="peach-snapshot-count">668 {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}669 </span>670 </button>671 )}672 </For>673 </div>674675 <Show when={selected() !== null}>676 <div class="peach-snapshot-confirm">677 <label class="peach-label" for="snapshot-pw">master password</label>678 <input679 id="snapshot-pw"680 class="peach-underline-input peach-unlock-input"681 type="password"682 placeholder="••••••••••••"683 autocomplete="current-password"684 value={password()}685 disabled={restoring()}686 onInput={(e) => setPassword((e.target as HTMLInputElement).value)}687 onKeyDown={(e) => {688 if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();689 }}690 />691692 <Show when={error()}>693 <p class="peach-hint peach-hint-error">{error()}</p>694 </Show>695696 <Show697 when={confirming()}698 fallback={699 <button700 class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"701 onClick={() => setConfirming(true)}702 disabled={!password().trim() || restoring()}703 >704 restore this version →705 </button>706 }707 >708 <div class="peach-snapshot-warning">709 <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">710 <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />711 <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />712 </svg>713 This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.714 </div>715 <div class="peach-snapshot-confirm-row">716 <button717 class="peach-btn peach-btn--accent peach-btn--sm"718 style="flex:1"719 onClick={() => void handleRestore()}720 disabled={restoring() || !password().trim()}721 >722 {restoring() ? "restoring…" : "yes, restore"}723 </button>724 <button725 class="peach-btn peach-btn--ghost peach-btn--sm"726 onClick={() => setConfirming(false)}727 disabled={restoring()}728 >729 cancel730 </button>731 </div>732 </Show>733 </div>734 </Show>735 </Show>736 </section>737 </section>738 );739}740741742743async function handleBiometricUnlock(props: { onUnlock: () => void | Promise<void> }) {744 const unlocked = await biometricUnlock();745 if (unlocked) {746 await props.onUnlock();747 }748}749750function BiometricUnlockButton(props: { ready: boolean; loading: () => boolean; onUnlock: () => void | Promise<void> }) {751 const [bioAvailable, setBioAvailable] = createSignal(false);752 onMount(async () => {753 setBioAvailable(await biometricAvailable());754 });755 return (756 <Show when={bioAvailable()}>757 <button758 class="peach-btn peach-btn--subtle peach-btn--md peach-biometric-btn"759 onClick={() => void handleBiometricUnlock(props)}760 disabled={!props.ready || props.loading()}761 type="button"762 >763 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">764 <path d="M8 1a3 3 0 0 0-3 3v4a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />765 <path d="M5 8v1a3 3 0 0 0 6 0V8" />766 <line x1="8" y1="12" x2="8" y2="15" />767 <line x1="5" y1="15" x2="11" y2="15" />768 </svg>769 use biometrics770 </button>771 </Show>772 );773}774775776777function getDisplayError(error: string | undefined, fallback: string): string {778 if (!error) return fallback;779 if (780 error === "SERVICE_WORKER_UNAVAILABLE" ||781 error.includes("Receiving end does not exist") ||782 error.includes("message port closed")783 ) {784 return "background service unavailable";785 }786 return error;787}