Lock.tsx

28.0 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  const createBlockerMessage = createMemo(() => {64    if (!password()) return null;65    if (password().length < MIN_LENGTH) return `need ${MIN_LENGTH - password().length} more characters`;66    if (!confirmPassword()) return "confirm your password";67    if (password() !== confirmPassword()) return "passwords don't match";68    if (strengthScore() === null) return "checking strength…";69    if (strengthScore()! < MIN_ZXCVBN_SCORE) return "password too weak — add words or length";70    return null;71  });7273  // Update strength asynchronously when password changes74  createMemo(() => {75    const value = password();76    if (!value) { setStrengthScore(null); return; }77    getZxcvbn().then((zxcvbn) => {78      if (password() === value) setStrengthScore(zxcvbn(value).score);79    }).catch(() => {80      // zxcvbn unavailable — fall back to length-only heuristic81      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);82    });83  });8485  async function handleUnlock() {86    if (!password().trim()) return;87    setLoading(true);88    setError("");89    const response = await sendMessage({90      type: "UNLOCK",91      payload: { password: password() },92    });93    const result = response as { ok: boolean; error?: string };94    if (result.ok) {95      await props.onUnlock();96      setLoading(false);97      return;98    }99    setError(getDisplayError(result.error, "incorrect password"));100    setPassword("");101    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;102    passwordField?.classList.add("shake");103    setTimeout(() => passwordField?.classList.remove("shake"), 300);104    setLoading(false);105  }106107  async function handleCreate() {108    if (!password().trim()) return;109    if (password().length < MIN_LENGTH) {110      setError(`password must be at least ${MIN_LENGTH} characters`);111      return;112    }113    if (password() !== confirmPassword()) {114      setError("passwords do not match");115      return;116    }117    const score = strengthScore();118    if (score === null || score < MIN_ZXCVBN_SCORE) {119      setError("password is too weak — try a longer or more varied passphrase");120      return;121    }122    setLoading(true);123    setError("");124    const response = await sendMessage({125      type: "CREATE_VAULT",126      payload: { password: password() },127    });128    const result = response as { ok: boolean; error?: string };129    if (result.ok) {130      await props.onUnlock();131      setLoading(false);132      return;133    }134    setError(getDisplayError(result.error, "failed to create vault"));135    setLoading(false);136  }137138  function openRestore(surface: Exclude<RestoreSurface, null>) {139    if (!props.ready || loading()) return;140    setPassword("");141    setConfirmPassword("");142    setError("");143    setRestoreSurface(surface);144  }145146  // ── All surfaces routed reactively via Switch/Match ───────────────────147  return (148    <Switch>149      {/* ── Restore picker ────────────────────────────────────────── */}150      <Match when={restoreSurface() === "restore"}>151        <RestorePickerView152          onBack={() => setRestoreSurface(null)}153          onPickPeachScript={() => setRestoreSurface("peachscript")}154          onPickMayan={() => setRestoreSurface("mayan")}155          onPickCSV={() => setRestoreSurface("file")}156          onPickSnapshots={() => setRestoreSurface("snapshots")}157        />158      </Match>159160      {/* ── Mayan code restore (decoder coming soon) ──────────────── */}161      <Match when={restoreSurface() === "mayan"}>162        <MayanComingSoonView onBack={() => setRestoreSurface("restore")} />163      </Match>164165      {/* ── Vault snapshot history ────────────────────────────────── */}166      <Match when={restoreSurface() === "snapshots"}>167        <SnapshotRestoreView168          onBack={() => setRestoreSurface("restore")}169          onRestored={props.onUnlock}170        />171      </Match>172173      {/* ── PeachScript restore ───────────────────────────────────── */}174      <Match when={restoreSurface() === "peachscript"}>175        <ImportView176          mode="onboarding"177          replacesExistingVault={props.hasVault}178          initialView="peachscript"179          onBack={() => setRestoreSurface("restore")}180          onImport={async () => {181            setRestoreSurface(null);182            await props.onUnlock();183          }}184        />185      </Match>186187      {/* ── File restore ──────────────────────────────────────────── */}188      <Match when={restoreSurface() === "file"}>189        <ImportView190          mode="onboarding"191          replacesExistingVault={props.hasVault}192          initialView="file"193          onBack={() => setRestoreSurface("restore")}194          onImport={async () => {195            setRestoreSurface(null);196            await props.onUnlock();197          }}198        />199      </Match>200201      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}202      <Match when={props.hasVault}>203        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">204          <header class="peach-screen-header lock-screen-header">205            <div class="peach-wordmark-stack">206              <PeachWordmark class="peach-wordmark-wrap" />207              <Show when={props.isPro}>208                <span class="peach-pro-mini-badge">pro</span>209              </Show>210            </div>211          </header>212213          <div class="peach-lock-hero peach-lock-hero--unlock">214            <div class="peach-lock-hero-art" aria-hidden="true">215              <PeachMark class="peach-lock-hero-mark" />216            </div>217            <span class="peach-lock-eyebrow">vault locked</span>218            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">219              unlock220              <br />221              your vault.222            </h1>223          </div>224225          <LockRuneCanvas />226227          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">228            <label class="peach-label" for="unlock-password">229              master password230            </label>231            <input232              id="unlock-password"233              class="peach-underline-input peach-unlock-input lock-password-input"234              type="password"235              placeholder="••••••••••••"236              autocomplete="current-password"237              value={password()}238              disabled={!props.ready}239              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}240              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}241              onKeyDown={(event) => {242                if (event.key !== "Enter" || !props.ready) return;243                void handleUnlock();244              }}245            />246247            <Show when={error()}>248              <p class="peach-hint peach-hint-error">{error()}</p>249            </Show>250251            <button252              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"253              onClick={() => void handleUnlock()}254              disabled={!props.ready || loading()}255            >256              {loading() ? "unlocking..." : "unlock"}257            </button>258          </section>259        </section>260      </Match>261262      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}263      <Match when={true}>264        <section265          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"266          data-motion-phase="enter"267        >268          <header class="peach-screen-header peach-newvault-header">269            <Show when={props.onBackToOnboarding}>270              <button271                class="peach-newvault-back"272                type="button"273                onClick={() => props.onBackToOnboarding?.()}274                aria-label="Back to onboarding"275              >276                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">277                  <path d="M7 2L3 5.5L7 9" />278                </svg>279                back280              </button>281            </Show>282            <div class="peach-wordmark-stack">283              <PeachWordmark class="peach-wordmark-wrap" />284              <Show when={props.isPro}>285                <span class="peach-pro-mini-badge">pro</span>286              </Show>287            </div>288          </header>289290          <div class="peach-newvault-mark" aria-hidden="true">291            <PeachMark class="peach-newvault-mark-art" />292          </div>293294          <div class="peach-newvault-card">295            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>296            <hr class="peach-newvault-divider" />297298            <div class="peach-newvault-field">299              <div class="peach-newvault-label-row">300                <label class="peach-label" for="master-password">301                  master password302                </label>303                <button304                  class="peach-lock-reveal-btn"305                  type="button"306                  onClick={() => setShowPassword((value) => !value)}307                  aria-label={showPassword() ? "Hide password" : "Show password"}308                >309                  <Show310                    when={showPassword()}311                    fallback={312                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">313                        <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" />314                        <circle cx="7" cy="5" r="1.75" />315                      </svg>316                    }317                  >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 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />320                      <path d="M3.5 5.5L2.5 7.5" />321                      <path d="M7 6.5V8.5" />322                      <path d="M10.5 5.5L11.5 7.5" />323                    </svg>324                  </Show>325                  {showPassword() ? "hide" : "show"}326                </button>327              </div>328              <input329                id="master-password"330                class="peach-underline-input peach-newvault-input lock-password-input"331                type={showPassword() ? "text" : "password"}332                placeholder="••••••••••••"333                autocomplete="new-password"334                value={password()}335                disabled={!props.ready}336                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}337                onKeyDown={(event) => {338                  if (event.key !== "Enter" || !props.ready) return;339                  void handleCreate();340                }}341              />342              <Show343                when={password().length > 0 && passwordStrength() !== null}344                fallback={345                  <p class="peach-newvault-hint">at least 12 characters</p>346                }347              >348                <div class="peach-password-strength">349                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>350                    <div class="peach-strength-fill"></div>351                  </div>352                  <span class="peach-strength-label">{passwordStrength()}</span>353                </div>354              </Show>355            </div>356357            <div class="peach-newvault-field">358              <label class="peach-label" for="confirm-password">359                confirm password360              </label>361              <input362                id="confirm-password"363                class="peach-underline-input peach-newvault-input"364                type={showPassword() ? "text" : "password"}365                placeholder="••••••••••••"366                autocomplete="new-password"367                value={confirmPassword()}368                disabled={!props.ready}369                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}370                onKeyDown={(event) => {371                  if (event.key === "Enter" && props.ready) void handleCreate();372                }}373              />374            </div>375376            <Show when={error()}>377              <p class="peach-hint peach-hint-error">{error()}</p>378            </Show>379380            <button381              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"382              onClick={() => void handleCreate()}383              disabled={!props.ready || loading() || !canCreate()}384              aria-live="polite"385            >386              <Show387                when={!canCreate() && createBlockerMessage()}388                fallback={loading() ? "creating..." : "create vault"}389              >390                {createBlockerMessage()}391              </Show>392            </button>393394            <p class="peach-newvault-warning">395              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>396              <span>If you forget it, your vault cannot be opened. There is no reset.</span>397            </p>398          </div>399400          <div class="peach-newvault-footer">401            <p class="peach-hint">Already have a backup?</p>402            <button403              class="peach-lock-restore-link"404              onClick={() => openRestore("restore")}405              disabled={!props.ready}406            >407              › restore from backup408            </button>409          </div>410        </section>411      </Match>412    </Switch>413  );414}415416// ── Restore method picker ──────────────────────────────────────────────────417418function RestorePickerView(props: {419  onBack: () => void;420  onPickPeachScript: () => void;421  onPickMayan: () => void;422  onPickCSV: () => void;423  onPickSnapshots: () => void;424}) {425  return (426    <section427      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"428      data-motion-phase="enter"429    >430      <header class="peach-screen-header lock-screen-header">431        <PeachWordmark class="peach-wordmark-wrap" />432        <button class="peach-lock-restore-link" onClick={props.onBack}>433          ‹ back434        </button>435      </header>436437      <div class="peach-lock-hero">438        <span class="peach-lock-eyebrow">restore</span>439        <h1 class="peach-onboarding-headline">440          bring back441          <br />442          your vault.443        </h1>444      </div>445446      <div class="peach-restore-stack" role="list">447        <button448          class="peach-restore-card"449          role="listitem"450          onClick={props.onPickPeachScript}451        >452          <h2 class="peach-restore-card-headline">PeachScript</h2>453          <hr class="peach-restore-card-divider" />454          <p class="peach-restore-card-body">455            Paste a string of Peach symbols, or open a <code>.peach</code> file456            you saved from another device.457          </p>458          <p class="peach-restore-card-meta">backup password required</p>459        </button>460461        <button462          class="peach-restore-card"463          role="listitem"464          onClick={props.onPickMayan}465        >466          <span class="peach-restore-card-soon" aria-label="coming soon">soon</span>467          <h2 class="peach-restore-card-headline">Mayan code</h2>468          <hr class="peach-restore-card-divider" />469          <p class="peach-restore-card-body">470            Restore from a printed Mayan code by uploading a photo of it.471          </p>472          <p class="peach-restore-card-meta">decoder shipping soon</p>473        </button>474475        <button476          class="peach-restore-card"477          role="listitem"478          onClick={props.onPickCSV}479        >480          <h2 class="peach-restore-card-headline">CSV</h2>481          <hr class="peach-restore-card-divider" />482          <p class="peach-restore-card-body">483            Move in from Bitwarden, 1Password, Proton Pass, or a browser484            (Chrome, Safari, Edge) CSV export.485          </p>486          <p class="peach-restore-card-meta">plain-text — open the file once and delete it</p>487        </button>488      </div>489490      <div class="peach-restore-footer">491        <button492          class="peach-restore-footer-link"493          onClick={props.onPickSnapshots}494        >495          or revert to a local snapshot →496        </button>497      </div>498    </section>499  );500}501502// ── Mayan code (decoder coming soon) ───────────────────────────────────────503504function MayanComingSoonView(props: { onBack: () => void }) {505  return (506    <section507      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding peach-restore-screen"508      data-motion-phase="enter"509    >510      <header class="peach-screen-header lock-screen-header">511        <PeachWordmark class="peach-wordmark-wrap" />512        <button class="peach-lock-restore-link" onClick={props.onBack}>513          ‹ back514        </button>515      </header>516517      <div class="peach-lock-hero">518        <span class="peach-lock-eyebrow">mayan code</span>519        <h1 class="peach-onboarding-headline">520          decoder521          <br />522          on its way.523        </h1>524      </div>525526      <div class="peach-restore-stack">527        <div class="peach-restore-card peach-restore-card--static">528          <h2 class="peach-restore-card-headline">Not yet — but soon.</h2>529          <hr class="peach-restore-card-divider" />530          <p class="peach-restore-card-body">531            Mayan codes encode the same backup as PeachScript, but printable on532            a single page. The phone-camera decoder is the next thing to land533            for this format.534          </p>535          <p class="peach-restore-card-body">536            Until then, restore your vault using the PeachScript that came537            with this Mayan code, or open a <code>.peach</code> file backup.538          </p>539        </div>540      </div>541542      <div class="peach-restore-footer">543        <button class="peach-restore-footer-link" onClick={props.onBack}>544          ‹ back to restore545        </button>546      </div>547    </section>548  );549}550551// ── Vault snapshot restore ─────────────────────────────────────────────────552553interface SnapshotSummary {554  index: number;555  timestamp: number;556  entryCount: number;557}558559function SnapshotRestoreView(props: {560  onBack: () => void;561  onRestored: () => void | Promise<void>;562}) {563  const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);564  const [loading, setLoading] = createSignal(true);565  const [selected, setSelected] = createSignal<number | null>(null);566  const [password, setPassword] = createSignal("");567  const [confirming, setConfirming] = createSignal(false);568  const [restoring, setRestoring] = createSignal(false);569  const [error, setError] = createSignal("");570571  onMount(async () => {572    const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });573    setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);574    setLoading(false);575  });576577  function formatAge(ts: number): string {578    const diff = Date.now() - ts;579    const mins = Math.floor(diff / 60_000);580    if (mins < 2) return "just now";581    if (mins < 60) return `${mins} minutes ago`;582    const hours = Math.floor(mins / 60);583    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;584    const days = Math.floor(hours / 24);585    return `${days} day${days === 1 ? "" : "s"} ago`;586  }587588  function selectSnapshot(index: number) {589    setSelected(index);590    setConfirming(false);591    setPassword("");592    setError("");593  }594595  async function handleRestore() {596    const idx = selected();597    if (idx === null || !password()) return;598    setRestoring(true);599    setError("");600    const res = await sendMessage({601      type: "RESTORE_VAULT_SNAPSHOT",602      payload: { index: idx, password: password() },603    });604    if ((res as { ok: boolean }).ok) {605      await props.onRestored();606    } else {607      setError("Incorrect password, or this snapshot cannot be decrypted.");608      setRestoring(false);609    }610  }611612  const selectedSnap = () => {613    const idx = selected();614    return idx !== null ? (snapshots()[idx] ?? null) : null;615  };616617  return (618    <section619      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"620      data-motion-phase="enter"621    >622      <header class="peach-screen-header lock-screen-header">623        <PeachWordmark class="peach-wordmark-wrap" />624        <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>625          ‹ back626        </button>627      </header>628629      <div class="peach-lock-hero">630        <span class="peach-lock-eyebrow">vault history</span>631        <h1 class="peach-onboarding-headline">632          revert to633          <br />634          a snapshot.635        </h1>636      </div>637638      <section class="peach-lock-panel peach-snapshot-panel">639        <Show when={loading()}>640          <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>641        </Show>642643        <Show when={!loading() && snapshots().length === 0}>644          <p class="peach-hint" style="line-height:1.6;">645            No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.646          </p>647        </Show>648649        <Show when={!loading() && snapshots().length > 0}>650          <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>651          <div class="peach-snapshot-list">652            <For each={snapshots()}>653              {(snap) => (654                <button655                  class="peach-snapshot-row"656                  classList={{ "is-selected": selected() === snap.index }}657                  onClick={() => selectSnapshot(snap.index)}658                  disabled={restoring()}659                >660                  <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>661                  <span class="peach-snapshot-count">662                    {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}663                  </span>664                </button>665              )}666            </For>667          </div>668669          <Show when={selected() !== null}>670            <div class="peach-snapshot-confirm">671              <label class="peach-label" for="snapshot-pw">master password</label>672              <input673                id="snapshot-pw"674                class="peach-underline-input peach-unlock-input"675                type="password"676                placeholder="••••••••••••"677                autocomplete="current-password"678                value={password()}679                disabled={restoring()}680                onInput={(e) => setPassword((e.target as HTMLInputElement).value)}681                onKeyDown={(e) => {682                  if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();683                }}684              />685686              <Show when={error()}>687                <p class="peach-hint peach-hint-error">{error()}</p>688              </Show>689690              <Show691                when={confirming()}692                fallback={693                  <button694                    class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"695                    onClick={() => setConfirming(true)}696                    disabled={!password().trim() || restoring()}697                  >698                    restore this version →699                  </button>700                }701              >702                <div class="peach-snapshot-warning">703                  <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">704                    <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />705                    <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />706                  </svg>707                  This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.708                </div>709                <div class="peach-snapshot-confirm-row">710                  <button711                    class="peach-btn peach-btn--accent peach-btn--sm"712                    style="flex:1"713                    onClick={() => void handleRestore()}714                    disabled={restoring() || !password().trim()}715                  >716                    {restoring() ? "restoring…" : "yes, restore"}717                  </button>718                  <button719                    class="peach-btn peach-btn--ghost peach-btn--sm"720                    onClick={() => setConfirming(false)}721                    disabled={restoring()}722                  >723                    cancel724                  </button>725                </div>726              </Show>727            </div>728          </Show>729        </Show>730      </section>731    </section>732  );733}734735// ── Helpers ────────────────────────────────────────────────────────────────736737function getDisplayError(error: string | undefined, fallback: string): string {738  if (!error) return fallback;739  if (740    error === "SERVICE_WORKER_UNAVAILABLE" ||741    error.includes("Receiving end does not exist") ||742    error.includes("message port closed")743  ) {744    return "background service unavailable";745  }746  return error;747}