Lock.tsx

23.7 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}2324// "restore" = picker, "snapshots" = vault history, "peachscript" / "file" = detail flows25type RestoreSurface = "restore" | "snapshots" | "peachscript" | "file" | null;2627export function LockView(props: LockProps) {28  const [password, setPassword] = createSignal("");29  const [confirmPassword, setConfirmPassword] = createSignal("");30  const [error, setError] = createSignal("");31  const [loading, setLoading] = createSignal(false);32  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);33  const [showPassword, setShowPassword] = createSignal(false);3435  const MIN_LENGTH = 12;36  const MIN_ZXCVBN_SCORE = 2;3738  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);3940  const canCreate = createMemo(41    () =>42      password().length >= MIN_LENGTH &&43      confirmPassword().length >= MIN_LENGTH &&44      password() === confirmPassword() &&45      strengthScore() !== null &&46      strengthScore()! >= MIN_ZXCVBN_SCORE47  );4849  const passwordStrength = createMemo(() => {50    const score = strengthScore();51    if (score === null) return null;52    if (score <= 1) return "weak";53    if (score === 2) return "fair";54    if (score === 3) return "good";55    return "strong";56  });5758  // Update strength asynchronously when password changes59  createMemo(() => {60    const value = password();61    if (!value) { setStrengthScore(null); return; }62    getZxcvbn().then((zxcvbn) => {63      if (password() === value) setStrengthScore(zxcvbn(value).score);64    }).catch(() => {65      // zxcvbn unavailable — fall back to length-only heuristic66      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);67    });68  });6970  async function handleUnlock() {71    if (!password().trim()) return;72    setLoading(true);73    setError("");74    const response = await sendMessage({75      type: "UNLOCK",76      payload: { password: password() },77    });78    const result = response as { ok: boolean; error?: string };79    if (result.ok) {80      await props.onUnlock();81      setLoading(false);82      return;83    }84    setError(getDisplayError(result.error, "incorrect password"));85    setPassword("");86    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;87    passwordField?.classList.add("shake");88    setTimeout(() => passwordField?.classList.remove("shake"), 300);89    setLoading(false);90  }9192  async function handleCreate() {93    if (!password().trim()) return;94    if (password().length < MIN_LENGTH) {95      setError(`password must be at least ${MIN_LENGTH} characters`);96      return;97    }98    if (password() !== confirmPassword()) {99      setError("passwords do not match");100      return;101    }102    const score = strengthScore();103    if (score === null || score < MIN_ZXCVBN_SCORE) {104      setError("password is too weak — try a longer or more varied passphrase");105      return;106    }107    setLoading(true);108    setError("");109    const response = await sendMessage({110      type: "CREATE_VAULT",111      payload: { password: password() },112    });113    const result = response as { ok: boolean; error?: string };114    if (result.ok) {115      await props.onUnlock();116      setLoading(false);117      return;118    }119    setError(getDisplayError(result.error, "failed to create vault"));120    setLoading(false);121  }122123  function openRestore(surface: Exclude<RestoreSurface, null>) {124    if (!props.ready || loading()) return;125    setPassword("");126    setConfirmPassword("");127    setError("");128    setRestoreSurface(surface);129  }130131  // ── All surfaces routed reactively via Switch/Match ───────────────────132  return (133    <Switch>134      {/* ── Restore picker ────────────────────────────────────────── */}135      <Match when={restoreSurface() === "restore"}>136        <RestorePickerView137          onBack={() => setRestoreSurface(null)}138          onPickPeachScript={() => setRestoreSurface("peachscript")}139          onPickFile={() => setRestoreSurface("file")}140          onPickSnapshots={() => setRestoreSurface("snapshots")}141        />142      </Match>143144      {/* ── Vault snapshot history ────────────────────────────────── */}145      <Match when={restoreSurface() === "snapshots"}>146        <SnapshotRestoreView147          onBack={() => setRestoreSurface("restore")}148          onRestored={props.onUnlock}149        />150      </Match>151152      {/* ── PeachScript restore ───────────────────────────────────── */}153      <Match when={restoreSurface() === "peachscript"}>154        <ImportView155          mode="onboarding"156          replacesExistingVault={props.hasVault}157          initialView="peachscript"158          onBack={() => setRestoreSurface("restore")}159          onImport={async () => {160            setRestoreSurface(null);161            await props.onUnlock();162          }}163        />164      </Match>165166      {/* ── File restore ──────────────────────────────────────────── */}167      <Match when={restoreSurface() === "file"}>168        <ImportView169          mode="onboarding"170          replacesExistingVault={props.hasVault}171          initialView="file"172          onBack={() => setRestoreSurface("restore")}173          onImport={async () => {174            setRestoreSurface(null);175            await props.onUnlock();176          }}177        />178      </Match>179180      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}181      <Match when={props.hasVault}>182        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">183          <header class="peach-screen-header lock-screen-header">184            <div class="peach-wordmark-stack">185              <PeachWordmark class="peach-wordmark-wrap" />186              <Show when={props.isPro}>187                <span class="peach-pro-mini-badge">pro</span>188              </Show>189            </div>190          </header>191192          <div class="peach-lock-hero peach-lock-hero--unlock">193            <div class="peach-lock-hero-art" aria-hidden="true">194              <PeachMark class="peach-lock-hero-mark" />195            </div>196            <span class="peach-lock-eyebrow">vault locked</span>197            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">198              unlock199              <br />200              your vault.201            </h1>202          </div>203204          <LockRuneCanvas />205206          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">207            <label class="peach-label" for="unlock-password">208              master password209            </label>210            <input211              id="unlock-password"212              class="peach-underline-input peach-unlock-input lock-password-input"213              type="password"214              placeholder="••••••••••••"215              autocomplete="current-password"216              value={password()}217              disabled={!props.ready}218              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}219              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}220              onKeyDown={(event) => {221                if (event.key !== "Enter" || !props.ready) return;222                void handleUnlock();223              }}224            />225226            <Show when={error()}>227              <p class="peach-hint peach-hint-error">{error()}</p>228            </Show>229230            <button231              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"232              onClick={() => void handleUnlock()}233              disabled={!props.ready || loading()}234            >235              {loading() ? "unlocking..." : "unlock"}236            </button>237          </section>238        </section>239      </Match>240241      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}242      <Match when={true}>243        <section244          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"245          data-motion-phase="enter"246        >247          <header class="peach-screen-header peach-newvault-header">248            <div class="peach-wordmark-stack">249              <PeachWordmark class="peach-wordmark-wrap" />250              <Show when={props.isPro}>251                <span class="peach-pro-mini-badge">pro</span>252              </Show>253            </div>254          </header>255256          <div class="peach-newvault-mark" aria-hidden="true">257            <PeachMark class="peach-newvault-mark-art" />258          </div>259260          <div class="peach-newvault-card">261            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>262            <hr class="peach-newvault-divider" />263264            <div class="peach-newvault-field">265              <div class="peach-newvault-label-row">266                <label class="peach-label" for="master-password">267                  master password268                </label>269                <button270                  class="peach-lock-reveal-btn"271                  type="button"272                  onClick={() => setShowPassword((value) => !value)}273                  aria-label={showPassword() ? "Hide password" : "Show password"}274                >275                  <Show276                    when={showPassword()}277                    fallback={278                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">279                        <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" />280                        <circle cx="7" cy="5" r="1.75" />281                      </svg>282                    }283                  >284                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">285                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />286                      <path d="M3.5 5.5L2.5 7.5" />287                      <path d="M7 6.5V8.5" />288                      <path d="M10.5 5.5L11.5 7.5" />289                    </svg>290                  </Show>291                  {showPassword() ? "hide" : "show"}292                </button>293              </div>294              <input295                id="master-password"296                class="peach-underline-input peach-newvault-input lock-password-input"297                type={showPassword() ? "text" : "password"}298                placeholder="••••••••••••"299                autocomplete="new-password"300                value={password()}301                disabled={!props.ready}302                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}303                onKeyDown={(event) => {304                  if (event.key !== "Enter" || !props.ready) return;305                  void handleCreate();306                }}307              />308              <Show309                when={password().length > 0 && passwordStrength() !== null}310                fallback={311                  <p class="peach-newvault-hint">at least 12 characters</p>312                }313              >314                <div class="peach-password-strength">315                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>316                    <div class="peach-strength-fill"></div>317                  </div>318                  <span class="peach-strength-label">{passwordStrength()}</span>319                </div>320              </Show>321            </div>322323            <div class="peach-newvault-field">324              <label class="peach-label" for="confirm-password">325                confirm password326              </label>327              <input328                id="confirm-password"329                class="peach-underline-input peach-newvault-input"330                type={showPassword() ? "text" : "password"}331                placeholder="••••••••••••"332                autocomplete="new-password"333                value={confirmPassword()}334                disabled={!props.ready}335                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}336                onKeyDown={(event) => {337                  if (event.key === "Enter" && props.ready) void handleCreate();338                }}339              />340            </div>341342            <Show when={error()}>343              <p class="peach-hint peach-hint-error">{error()}</p>344            </Show>345346            <button347              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"348              onClick={() => void handleCreate()}349              disabled={!props.ready || loading() || !canCreate()}350            >351              {loading() ? "creating..." : "create vault"}352            </button>353354            <p class="peach-newvault-warning">355              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>356              <span>If you forget it, your vault cannot be opened. There is no reset.</span>357            </p>358          </div>359360          <div class="peach-newvault-footer">361            <p class="peach-hint">Already have a backup?</p>362            <button363              class="peach-lock-restore-link"364              onClick={() => openRestore("restore")}365              disabled={!props.ready}366            >367              › restore from backup368            </button>369          </div>370        </section>371      </Match>372    </Switch>373  );374}375376// ── Restore method picker ──────────────────────────────────────────────────377378function RestorePickerView(props: {379  onBack: () => void;380  onPickPeachScript: () => void;381  onPickFile: () => void;382  onPickSnapshots: () => void;383}) {384  return (385    <section386      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"387      data-motion-phase="enter"388    >389      <header class="peach-screen-header lock-screen-header">390        <PeachWordmark class="peach-wordmark-wrap" />391        <button class="peach-lock-restore-link" onClick={props.onBack}>392          ‹ back393        </button>394      </header>395396      <div class="peach-lock-hero">397        <span class="peach-lock-eyebrow">restore</span>398        <h1 class="peach-onboarding-headline">399          bring back400          <br />401          your vault.402        </h1>403      </div>404405      <section class="peach-lock-panel peach-lock-panel--restore">406        <p class="peach-hint" style="margin-bottom:2px;">How did you back up your data?</p>407408        <button class="peach-lock-option" onClick={props.onPickPeachScript}>409          <span class="peach-lock-option-title">peachscript</span>410          <span class="peach-lock-option-copy">411            A string of Peach symbols you exported from another Peach device. You'll need the backup password you set at the time.412          </span>413        </button>414415        <button class="peach-lock-option" onClick={props.onPickFile}>416          <span class="peach-lock-option-title">backup file</span>417          <span class="peach-lock-option-copy">418            A Peach JSON export, or a file from Bitwarden, Proton Pass, 1Password, or a browser (Chrome, Safari, Edge CSV).419          </span>420        </button>421422        <button class="peach-lock-option peach-lock-option--history" onClick={props.onPickSnapshots}>423          <span class="peach-lock-option-title">vault history</span>424          <span class="peach-lock-option-copy">425            Peach saves encrypted snapshots locally after each change. Revert to an earlier version if something went wrong.426          </span>427        </button>428      </section>429    </section>430  );431}432433// ── Vault snapshot restore ─────────────────────────────────────────────────434435interface SnapshotSummary {436  index: number;437  timestamp: number;438  entryCount: number;439}440441function SnapshotRestoreView(props: {442  onBack: () => void;443  onRestored: () => void | Promise<void>;444}) {445  const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);446  const [loading, setLoading] = createSignal(true);447  const [selected, setSelected] = createSignal<number | null>(null);448  const [password, setPassword] = createSignal("");449  const [confirming, setConfirming] = createSignal(false);450  const [restoring, setRestoring] = createSignal(false);451  const [error, setError] = createSignal("");452453  onMount(async () => {454    const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });455    setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);456    setLoading(false);457  });458459  function formatAge(ts: number): string {460    const diff = Date.now() - ts;461    const mins = Math.floor(diff / 60_000);462    if (mins < 2) return "just now";463    if (mins < 60) return `${mins} minutes ago`;464    const hours = Math.floor(mins / 60);465    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;466    const days = Math.floor(hours / 24);467    return `${days} day${days === 1 ? "" : "s"} ago`;468  }469470  function selectSnapshot(index: number) {471    setSelected(index);472    setConfirming(false);473    setPassword("");474    setError("");475  }476477  async function handleRestore() {478    const idx = selected();479    if (idx === null || !password()) return;480    setRestoring(true);481    setError("");482    const res = await sendMessage({483      type: "RESTORE_VAULT_SNAPSHOT",484      payload: { index: idx, password: password() },485    });486    if ((res as { ok: boolean }).ok) {487      await props.onRestored();488    } else {489      setError("Incorrect password, or this snapshot cannot be decrypted.");490      setRestoring(false);491    }492  }493494  const selectedSnap = () => {495    const idx = selected();496    return idx !== null ? (snapshots()[idx] ?? null) : null;497  };498499  return (500    <section501      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"502      data-motion-phase="enter"503    >504      <header class="peach-screen-header lock-screen-header">505        <PeachWordmark class="peach-wordmark-wrap" />506        <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>507          ‹ back508        </button>509      </header>510511      <div class="peach-lock-hero">512        <span class="peach-lock-eyebrow">vault history</span>513        <h1 class="peach-onboarding-headline">514          revert to515          <br />516          a snapshot.517        </h1>518      </div>519520      <section class="peach-lock-panel peach-snapshot-panel">521        <Show when={loading()}>522          <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>523        </Show>524525        <Show when={!loading() && snapshots().length === 0}>526          <p class="peach-hint" style="line-height:1.6;">527            No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.528          </p>529        </Show>530531        <Show when={!loading() && snapshots().length > 0}>532          <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>533          <div class="peach-snapshot-list">534            <For each={snapshots()}>535              {(snap) => (536                <button537                  class="peach-snapshot-row"538                  classList={{ "is-selected": selected() === snap.index }}539                  onClick={() => selectSnapshot(snap.index)}540                  disabled={restoring()}541                >542                  <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>543                  <span class="peach-snapshot-count">544                    {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}545                  </span>546                </button>547              )}548            </For>549          </div>550551          <Show when={selected() !== null}>552            <div class="peach-snapshot-confirm">553              <label class="peach-label" for="snapshot-pw">master password</label>554              <input555                id="snapshot-pw"556                class="peach-underline-input peach-unlock-input"557                type="password"558                placeholder="••••••••••••"559                autocomplete="current-password"560                value={password()}561                disabled={restoring()}562                onInput={(e) => setPassword((e.target as HTMLInputElement).value)}563                onKeyDown={(e) => {564                  if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();565                }}566              />567568              <Show when={error()}>569                <p class="peach-hint peach-hint-error">{error()}</p>570              </Show>571572              <Show573                when={confirming()}574                fallback={575                  <button576                    class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"577                    onClick={() => setConfirming(true)}578                    disabled={!password().trim() || restoring()}579                  >580                    restore this version →581                  </button>582                }583              >584                <div class="peach-snapshot-warning">585                  <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">586                    <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />587                    <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />588                  </svg>589                  This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.590                </div>591                <div class="peach-snapshot-confirm-row">592                  <button593                    class="peach-btn peach-btn--accent peach-btn--sm"594                    style="flex:1"595                    onClick={() => void handleRestore()}596                    disabled={restoring() || !password().trim()}597                  >598                    {restoring() ? "restoring…" : "yes, restore"}599                  </button>600                  <button601                    class="peach-btn peach-btn--ghost peach-btn--sm"602                    onClick={() => setConfirming(false)}603                    disabled={restoring()}604                  >605                    cancel606                  </button>607                </div>608              </Show>609            </div>610          </Show>611        </Show>612      </section>613    </section>614  );615}616617// ── Helpers ────────────────────────────────────────────────────────────────618619function getDisplayError(error: string | undefined, fallback: string): string {620  if (!error) return fallback;621  if (622    error === "SERVICE_WORKER_UNAVAILABLE" ||623    error.includes("Receiving end does not exist") ||624    error.includes("message port closed")625  ) {626    return "background service unavailable";627  }628  return error;629}