Lock.tsx

25.0 KB · raw

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";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, "peachscript" / "file" = detail flows,31// "mayan" = Mayan-code import (decoder not shipped yet — picker exposes a "soon" card).32type 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  // 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        />159      </Match>160161      {/* ── Mayan code restore (decoder coming soon) ──────────────── */}162      <Match when={restoreSurface() === "mayan"}>163        <MayanRestoreView onBack={() => setRestoreSurface("restore")} />164      </Match>165166      {/* ── PeachScript restore ───────────────────────────────────── */}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      {/* ── File restore ──────────────────────────────────────────── */}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      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}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      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}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}412413// ── Restore method picker ──────────────────────────────────────────────────414415function 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}488489// ── Mayan code restore ─────────────────────────────────────────────────────490491/**492 * Decode a base64-encoded Mayan code image into the binary backup payload.493 * Decoder isn't shipped yet — return a typed result so the UI can surface494 * the right copy, and replace this single function when the decoder lands.495 */496async function handleMayanDecode(_base64: string): Promise<497  { ok: true; payload: Uint8Array } | { ok: false; reason: string }498> {499  // TODO(decoder): wire src/crypto/mayan.ts decode() here.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      // Future: hand payload to the restore-decrypt flow here.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      // User cancellation throws — only surface real errors.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}626627// ── Biometric unlock ────────────────────────────────────────────────────────628629async 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}660661// ── Helpers ────────────────────────────────────────────────────────────────662663function 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}