Lock.tsx

29.6 KB · raw

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";910// Lazy-load zxcvbn — it's heavy (~800 kB) and only needed during vault creation11let _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  /** Optional escape hatch: rewind from the create-vault screen back to25   *  Onboarding. Only meaningful when the user reached create-vault from26   *  Onboarding in the first place. */27  onBackToOnboarding?: () => void;28}2930// "restore" = picker, "snapshots" = vault history, "peachscript" / "file" = detail flows,31// "mayan" = Mayan-code import (decoder not shipped yet — picker exposes a "soon" card).32type 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  // Update strength asynchronously when password changes76  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      // zxcvbn unavailable — fall back to length-only heuristic83      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  // ── All surfaces routed reactively via Switch/Match ───────────────────149  return (150    <Switch>151      {/* ── Restore picker ────────────────────────────────────────── */}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      {/* ── Mayan code restore (decoder coming soon) ──────────────── */}163      <Match when={restoreSurface() === "mayan"}>164        <MayanComingSoonView onBack={() => setRestoreSurface("restore")} />165      </Match>166167      {/* ── Vault snapshot history ────────────────────────────────── */}168      <Match when={restoreSurface() === "snapshots"}>169        <SnapshotRestoreView170          onBack={() => setRestoreSurface("restore")}171          onRestored={props.onUnlock}172        />173      </Match>174175      {/* ── PeachScript restore ───────────────────────────────────── */}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      {/* ── File restore ──────────────────────────────────────────── */}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      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}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      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}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}421422// ── Restore method picker ──────────────────────────────────────────────────423424function 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}507508// ── Mayan code (decoder coming soon) ───────────────────────────────────────509510function 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}556557// ── Vault snapshot restore ─────────────────────────────────────────────────558559interface 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}740741// ── Biometric unlock ────────────────────────────────────────────────────────742743async 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}774775// ── Helpers ────────────────────────────────────────────────────────────────776777function 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}