Lock.tsx

27.3 KB · raw

1import { For, Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js";23import { sendMessage } from "../App";4import { LockRuneCanvas } from "../components/LockRuneCanvas";5import { PeachMark, PeachWordmark } from "../components/PeachMark";6import { ImportView } from "./Import";78// Lazy-load zxcvbn — it's heavy (~800 kB) and only needed during vault creation9let _zxcvbn: ((password: string) => { score: number }) | null = null;10async function getZxcvbn() {11  if (_zxcvbn) return _zxcvbn;12  const mod = await import("zxcvbn");13  _zxcvbn = mod.default;14  return _zxcvbn;15}1617interface LockProps {18  hasVault: boolean;19  ready: boolean;20  isPro?: boolean;21  onUnlock: () => void | Promise<void>;22  /** Optional escape hatch: rewind from the create-vault screen back to23   *  Onboarding. Only meaningful when the user reached create-vault from24   *  Onboarding in the first place. */25  onBackToOnboarding?: () => void;26}2728// "restore" = picker, "snapshots" = vault history, "peachscript" / "file" = detail flows,29// "mayan" = Mayan-code import (decoder not shipped yet — picker exposes a "soon" card).30type RestoreSurface = "restore" | "snapshots" | "peachscript" | "file" | "mayan" | null;3132export function LockView(props: LockProps) {33  const [password, setPassword] = createSignal("");34  const [confirmPassword, setConfirmPassword] = createSignal("");35  const [error, setError] = createSignal("");36  const [loading, setLoading] = createSignal(false);37  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);38  const [showPassword, setShowPassword] = createSignal(false);3940  const MIN_LENGTH = 12;41  const MIN_ZXCVBN_SCORE = 2;4243  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);4445  const canCreate = createMemo(46    () =>47      password().length >= MIN_LENGTH &&48      confirmPassword().length >= MIN_LENGTH &&49      password() === confirmPassword() &&50      strengthScore() !== null &&51      strengthScore()! >= MIN_ZXCVBN_SCORE52  );5354  const passwordStrength = createMemo(() => {55    const score = strengthScore();56    if (score === null) return null;57    if (score <= 1) return "weak";58    if (score === 2) return "fair";59    if (score === 3) return "good";60    return "strong";61  });6263  // Update strength asynchronously when password changes64  createMemo(() => {65    const value = password();66    if (!value) { setStrengthScore(null); return; }67    getZxcvbn().then((zxcvbn) => {68      if (password() === value) setStrengthScore(zxcvbn(value).score);69    }).catch(() => {70      // zxcvbn unavailable — fall back to length-only heuristic71      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);72    });73  });7475  async function handleUnlock() {76    if (!password().trim()) return;77    setLoading(true);78    setError("");79    const response = await sendMessage({80      type: "UNLOCK",81      payload: { password: password() },82    });83    const result = response as { ok: boolean; error?: string };84    if (result.ok) {85      await props.onUnlock();86      setLoading(false);87      return;88    }89    setError(getDisplayError(result.error, "incorrect password"));90    setPassword("");91    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;92    passwordField?.classList.add("shake");93    setTimeout(() => passwordField?.classList.remove("shake"), 300);94    setLoading(false);95  }9697  async function handleCreate() {98    if (!password().trim()) return;99    if (password().length < MIN_LENGTH) {100      setError(`password must be at least ${MIN_LENGTH} characters`);101      return;102    }103    if (password() !== confirmPassword()) {104      setError("passwords do not match");105      return;106    }107    const score = strengthScore();108    if (score === null || score < MIN_ZXCVBN_SCORE) {109      setError("password is too weak — try a longer or more varied passphrase");110      return;111    }112    setLoading(true);113    setError("");114    const response = await sendMessage({115      type: "CREATE_VAULT",116      payload: { password: password() },117    });118    const result = response as { ok: boolean; error?: string };119    if (result.ok) {120      await props.onUnlock();121      setLoading(false);122      return;123    }124    setError(getDisplayError(result.error, "failed to create vault"));125    setLoading(false);126  }127128  function openRestore(surface: Exclude<RestoreSurface, null>) {129    if (!props.ready || loading()) return;130    setPassword("");131    setConfirmPassword("");132    setError("");133    setRestoreSurface(surface);134  }135136  // ── All surfaces routed reactively via Switch/Match ───────────────────137  return (138    <Switch>139      {/* ── Restore picker ────────────────────────────────────────── */}140      <Match when={restoreSurface() === "restore"}>141        <RestorePickerView142          onBack={() => setRestoreSurface(null)}143          onPickPeachScript={() => setRestoreSurface("peachscript")}144          onPickMayan={() => setRestoreSurface("mayan")}145          onPickCSV={() => setRestoreSurface("file")}146          onPickSnapshots={() => setRestoreSurface("snapshots")}147        />148      </Match>149150      {/* ── Mayan code restore (decoder coming soon) ──────────────── */}151      <Match when={restoreSurface() === "mayan"}>152        <MayanComingSoonView onBack={() => setRestoreSurface("restore")} />153      </Match>154155      {/* ── Vault snapshot history ────────────────────────────────── */}156      <Match when={restoreSurface() === "snapshots"}>157        <SnapshotRestoreView158          onBack={() => setRestoreSurface("restore")}159          onRestored={props.onUnlock}160        />161      </Match>162163      {/* ── PeachScript restore ───────────────────────────────────── */}164      <Match when={restoreSurface() === "peachscript"}>165        <ImportView166          mode="onboarding"167          replacesExistingVault={props.hasVault}168          initialView="peachscript"169          onBack={() => setRestoreSurface("restore")}170          onImport={async () => {171            setRestoreSurface(null);172            await props.onUnlock();173          }}174        />175      </Match>176177      {/* ── File restore ──────────────────────────────────────────── */}178      <Match when={restoreSurface() === "file"}>179        <ImportView180          mode="onboarding"181          replacesExistingVault={props.hasVault}182          initialView="file"183          onBack={() => setRestoreSurface("restore")}184          onImport={async () => {185            setRestoreSurface(null);186            await props.onUnlock();187          }}188        />189      </Match>190191      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}192      <Match when={props.hasVault}>193        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">194          <header class="peach-screen-header lock-screen-header">195            <div class="peach-wordmark-stack">196              <PeachWordmark class="peach-wordmark-wrap" />197              <Show when={props.isPro}>198                <span class="peach-pro-mini-badge">pro</span>199              </Show>200            </div>201          </header>202203          <div class="peach-lock-hero peach-lock-hero--unlock">204            <div class="peach-lock-hero-art" aria-hidden="true">205              <PeachMark class="peach-lock-hero-mark" />206            </div>207            <span class="peach-lock-eyebrow">vault locked</span>208            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">209              unlock210              <br />211              your vault.212            </h1>213          </div>214215          <LockRuneCanvas />216217          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">218            <label class="peach-label" for="unlock-password">219              master password220            </label>221            <input222              id="unlock-password"223              class="peach-underline-input peach-unlock-input lock-password-input"224              type="password"225              placeholder="••••••••••••"226              autocomplete="current-password"227              value={password()}228              disabled={!props.ready}229              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}230              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}231              onKeyDown={(event) => {232                if (event.key !== "Enter" || !props.ready) return;233                void handleUnlock();234              }}235            />236237            <Show when={error()}>238              <p class="peach-hint peach-hint-error">{error()}</p>239            </Show>240241            <button242              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"243              onClick={() => void handleUnlock()}244              disabled={!props.ready || loading()}245            >246              {loading() ? "unlocking..." : "unlock"}247            </button>248          </section>249        </section>250      </Match>251252      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}253      <Match when={true}>254        <section255          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"256          data-motion-phase="enter"257        >258          <header class="peach-screen-header peach-newvault-header">259            <Show when={props.onBackToOnboarding}>260              <button261                class="peach-newvault-back"262                type="button"263                onClick={() => props.onBackToOnboarding?.()}264                aria-label="Back to onboarding"265              >266                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">267                  <path d="M7 2L3 5.5L7 9" />268                </svg>269                back270              </button>271            </Show>272            <div class="peach-wordmark-stack">273              <PeachWordmark class="peach-wordmark-wrap" />274              <Show when={props.isPro}>275                <span class="peach-pro-mini-badge">pro</span>276              </Show>277            </div>278          </header>279280          <div class="peach-newvault-mark" aria-hidden="true">281            <PeachMark class="peach-newvault-mark-art" />282          </div>283284          <div class="peach-newvault-card">285            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>286            <hr class="peach-newvault-divider" />287288            <div class="peach-newvault-field">289              <div class="peach-newvault-label-row">290                <label class="peach-label" for="master-password">291                  master password292                </label>293                <button294                  class="peach-lock-reveal-btn"295                  type="button"296                  onClick={() => setShowPassword((value) => !value)}297                  aria-label={showPassword() ? "Hide password" : "Show password"}298                >299                  <Show300                    when={showPassword()}301                    fallback={302                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">303                        <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" />304                        <circle cx="7" cy="5" r="1.75" />305                      </svg>306                    }307                  >308                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">309                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />310                      <path d="M3.5 5.5L2.5 7.5" />311                      <path d="M7 6.5V8.5" />312                      <path d="M10.5 5.5L11.5 7.5" />313                    </svg>314                  </Show>315                  {showPassword() ? "hide" : "show"}316                </button>317              </div>318              <input319                id="master-password"320                class="peach-underline-input peach-newvault-input lock-password-input"321                type={showPassword() ? "text" : "password"}322                placeholder="••••••••••••"323                autocomplete="new-password"324                value={password()}325                disabled={!props.ready}326                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}327                onKeyDown={(event) => {328                  if (event.key !== "Enter" || !props.ready) return;329                  void handleCreate();330                }}331              />332              <Show333                when={password().length > 0 && passwordStrength() !== null}334                fallback={335                  <p class="peach-newvault-hint">at least 12 characters</p>336                }337              >338                <div class="peach-password-strength">339                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>340                    <div class="peach-strength-fill"></div>341                  </div>342                  <span class="peach-strength-label">{passwordStrength()}</span>343                </div>344              </Show>345            </div>346347            <div class="peach-newvault-field">348              <label class="peach-label" for="confirm-password">349                confirm password350              </label>351              <input352                id="confirm-password"353                class="peach-underline-input peach-newvault-input"354                type={showPassword() ? "text" : "password"}355                placeholder="••••••••••••"356                autocomplete="new-password"357                value={confirmPassword()}358                disabled={!props.ready}359                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}360                onKeyDown={(event) => {361                  if (event.key === "Enter" && props.ready) void handleCreate();362                }}363              />364            </div>365366            <Show when={error()}>367              <p class="peach-hint peach-hint-error">{error()}</p>368            </Show>369370            <button371              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"372              onClick={() => void handleCreate()}373              disabled={!props.ready || loading() || !canCreate()}374            >375              {loading() ? "creating..." : "create vault"}376            </button>377378            <p class="peach-newvault-warning">379              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>380              <span>If you forget it, your vault cannot be opened. There is no reset.</span>381            </p>382          </div>383384          <div class="peach-newvault-footer">385            <p class="peach-hint">Already have a backup?</p>386            <button387              class="peach-lock-restore-link"388              onClick={() => openRestore("restore")}389              disabled={!props.ready}390            >391              › restore from backup392            </button>393          </div>394        </section>395      </Match>396    </Switch>397  );398}399400// ── Restore method picker ──────────────────────────────────────────────────401402function RestorePickerView(props: {403  onBack: () => void;404  onPickPeachScript: () => void;405  onPickMayan: () => void;406  onPickCSV: () => void;407  onPickSnapshots: () => void;408}) {409  return (410    <section411      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"412      data-motion-phase="enter"413    >414      <header class="peach-screen-header lock-screen-header">415        <PeachWordmark class="peach-wordmark-wrap" />416        <button class="peach-lock-restore-link" onClick={props.onBack}>417          ‹ back418        </button>419      </header>420421      <div class="peach-lock-hero">422        <span class="peach-lock-eyebrow">restore</span>423        <h1 class="peach-onboarding-headline">424          bring back425          <br />426          your vault.427        </h1>428      </div>429430      <div class="peach-restore-stack" role="list">431        <button432          class="peach-restore-card"433          role="listitem"434          onClick={props.onPickPeachScript}435        >436          <h2 class="peach-restore-card-headline">PeachScript</h2>437          <hr class="peach-restore-card-divider" />438          <p class="peach-restore-card-body">439            Paste a string of Peach symbols, or open a <code>.peach</code> file440            you saved from another device.441          </p>442          <p class="peach-restore-card-meta">backup password required</p>443        </button>444445        <button446          class="peach-restore-card"447          role="listitem"448          onClick={props.onPickMayan}449        >450          <span class="peach-restore-card-soon" aria-label="coming soon">soon</span>451          <h2 class="peach-restore-card-headline">Mayan code</h2>452          <hr class="peach-restore-card-divider" />453          <p class="peach-restore-card-body">454            Restore from a printed Mayan code by uploading a photo of it.455          </p>456          <p class="peach-restore-card-meta">decoder shipping soon</p>457        </button>458459        <button460          class="peach-restore-card"461          role="listitem"462          onClick={props.onPickCSV}463        >464          <h2 class="peach-restore-card-headline">CSV</h2>465          <hr class="peach-restore-card-divider" />466          <p class="peach-restore-card-body">467            Move in from Bitwarden, 1Password, Proton Pass, or a browser468            (Chrome, Safari, Edge) CSV export.469          </p>470          <p class="peach-restore-card-meta">plain-text — open the file once and delete it</p>471        </button>472      </div>473474      <div class="peach-restore-footer">475        <button476          class="peach-restore-footer-link"477          onClick={props.onPickSnapshots}478        >479          or revert to a local snapshot →480        </button>481      </div>482    </section>483  );484}485486// ── Mayan code (decoder coming soon) ───────────────────────────────────────487488function MayanComingSoonView(props: { onBack: () => void }) {489  return (490    <section491      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"492      data-motion-phase="enter"493    >494      <header class="peach-screen-header lock-screen-header">495        <PeachWordmark class="peach-wordmark-wrap" />496        <button class="peach-lock-restore-link" onClick={props.onBack}>497          ‹ back498        </button>499      </header>500501      <div class="peach-lock-hero">502        <span class="peach-lock-eyebrow">mayan code</span>503        <h1 class="peach-onboarding-headline">504          decoder505          <br />506          on its way.507        </h1>508      </div>509510      <div class="peach-restore-stack">511        <div class="peach-restore-card peach-restore-card--static">512          <h2 class="peach-restore-card-headline">Not yet — but soon.</h2>513          <hr class="peach-restore-card-divider" />514          <p class="peach-restore-card-body">515            Mayan codes encode the same backup as PeachScript, but printable on516            a single page. The phone-camera decoder is the next thing to land517            for this format.518          </p>519          <p class="peach-restore-card-body">520            Until then, restore your vault using the PeachScript that came521            with this Mayan code, or open a <code>.peach</code> file backup.522          </p>523        </div>524      </div>525526      <div class="peach-restore-footer">527        <button class="peach-restore-footer-link" onClick={props.onBack}>528          ‹ back to restore529        </button>530      </div>531    </section>532  );533}534535// ── Vault snapshot restore ─────────────────────────────────────────────────536537interface SnapshotSummary {538  index: number;539  timestamp: number;540  entryCount: number;541}542543function SnapshotRestoreView(props: {544  onBack: () => void;545  onRestored: () => void | Promise<void>;546}) {547  const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);548  const [loading, setLoading] = createSignal(true);549  const [selected, setSelected] = createSignal<number | null>(null);550  const [password, setPassword] = createSignal("");551  const [confirming, setConfirming] = createSignal(false);552  const [restoring, setRestoring] = createSignal(false);553  const [error, setError] = createSignal("");554555  onMount(async () => {556    const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });557    setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);558    setLoading(false);559  });560561  function formatAge(ts: number): string {562    const diff = Date.now() - ts;563    const mins = Math.floor(diff / 60_000);564    if (mins < 2) return "just now";565    if (mins < 60) return `${mins} minutes ago`;566    const hours = Math.floor(mins / 60);567    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;568    const days = Math.floor(hours / 24);569    return `${days} day${days === 1 ? "" : "s"} ago`;570  }571572  function selectSnapshot(index: number) {573    setSelected(index);574    setConfirming(false);575    setPassword("");576    setError("");577  }578579  async function handleRestore() {580    const idx = selected();581    if (idx === null || !password()) return;582    setRestoring(true);583    setError("");584    const res = await sendMessage({585      type: "RESTORE_VAULT_SNAPSHOT",586      payload: { index: idx, password: password() },587    });588    if ((res as { ok: boolean }).ok) {589      await props.onRestored();590    } else {591      setError("Incorrect password, or this snapshot cannot be decrypted.");592      setRestoring(false);593    }594  }595596  const selectedSnap = () => {597    const idx = selected();598    return idx !== null ? (snapshots()[idx] ?? null) : null;599  };600601  return (602    <section603      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"604      data-motion-phase="enter"605    >606      <header class="peach-screen-header lock-screen-header">607        <PeachWordmark class="peach-wordmark-wrap" />608        <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>609          ‹ back610        </button>611      </header>612613      <div class="peach-lock-hero">614        <span class="peach-lock-eyebrow">vault history</span>615        <h1 class="peach-onboarding-headline">616          revert to617          <br />618          a snapshot.619        </h1>620      </div>621622      <section class="peach-lock-panel peach-snapshot-panel">623        <Show when={loading()}>624          <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>625        </Show>626627        <Show when={!loading() && snapshots().length === 0}>628          <p class="peach-hint" style="line-height:1.6;">629            No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.630          </p>631        </Show>632633        <Show when={!loading() && snapshots().length > 0}>634          <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>635          <div class="peach-snapshot-list">636            <For each={snapshots()}>637              {(snap) => (638                <button639                  class="peach-snapshot-row"640                  classList={{ "is-selected": selected() === snap.index }}641                  onClick={() => selectSnapshot(snap.index)}642                  disabled={restoring()}643                >644                  <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>645                  <span class="peach-snapshot-count">646                    {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}647                  </span>648                </button>649              )}650            </For>651          </div>652653          <Show when={selected() !== null}>654            <div class="peach-snapshot-confirm">655              <label class="peach-label" for="snapshot-pw">master password</label>656              <input657                id="snapshot-pw"658                class="peach-underline-input peach-unlock-input"659                type="password"660                placeholder="••••••••••••"661                autocomplete="current-password"662                value={password()}663                disabled={restoring()}664                onInput={(e) => setPassword((e.target as HTMLInputElement).value)}665                onKeyDown={(e) => {666                  if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();667                }}668              />669670              <Show when={error()}>671                <p class="peach-hint peach-hint-error">{error()}</p>672              </Show>673674              <Show675                when={confirming()}676                fallback={677                  <button678                    class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"679                    onClick={() => setConfirming(true)}680                    disabled={!password().trim() || restoring()}681                  >682                    restore this version →683                  </button>684                }685              >686                <div class="peach-snapshot-warning">687                  <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">688                    <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />689                    <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />690                  </svg>691                  This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.692                </div>693                <div class="peach-snapshot-confirm-row">694                  <button695                    class="peach-btn peach-btn--accent peach-btn--sm"696                    style="flex:1"697                    onClick={() => void handleRestore()}698                    disabled={restoring() || !password().trim()}699                  >700                    {restoring() ? "restoring…" : "yes, restore"}701                  </button>702                  <button703                    class="peach-btn peach-btn--ghost peach-btn--sm"704                    onClick={() => setConfirming(false)}705                    disabled={restoring()}706                  >707                    cancel708                  </button>709                </div>710              </Show>711            </div>712          </Show>713        </Show>714      </section>715    </section>716  );717}718719// ── Helpers ────────────────────────────────────────────────────────────────720721function getDisplayError(error: string | undefined, fallback: string): string {722  if (!error) return fallback;723  if (724    error === "SERVICE_WORKER_UNAVAILABLE" ||725    error.includes("Receiving end does not exist") ||726    error.includes("message port closed")727  ) {728    return "background service unavailable";729  }730  return error;731}