Lock.tsx

23.2 KB · raw

1import { For, Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js";23import { sendMessage } from "../App";4import { PeachMark, PeachWordmark } from "../components/PeachMark";5import { CJKImportView } from "./CJKImport";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, "cjk" / "file" = detail flows25type RestoreSurface = "restore" | "snapshots" | "cjk" | "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          onPickCjk={() => setRestoreSurface("cjk")}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      {/* ── CJK restore ───────────────────────────────────────────── */}153      <Match when={restoreSurface() === "cjk"}>154        <CJKImportView155          mode="onboarding"156          onBack={() => setRestoreSurface("restore")}157          onDone={async () => {158            setRestoreSurface(null);159            await props.onUnlock();160          }}161        />162      </Match>163164      {/* ── File restore ──────────────────────────────────────────── */}165      <Match when={restoreSurface() === "file"}>166        <ImportView167          mode="onboarding"168          replacesExistingVault={props.hasVault}169          onBack={() => setRestoreSurface("restore")}170          onImport={async () => {171            setRestoreSurface(null);172            await props.onUnlock();173          }}174        />175      </Match>176177      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}178      <Match when={props.hasVault}>179        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">180          <header class="peach-screen-header lock-screen-header">181            <div class="peach-wordmark-stack">182              <PeachWordmark class="peach-wordmark-wrap" />183              <Show when={props.isPro}>184                <span class="peach-pro-mini-badge">pro</span>185              </Show>186            </div>187          </header>188189          <div class="peach-lock-hero peach-lock-hero--unlock">190            <div class="peach-lock-hero-art" aria-hidden="true">191              <PeachMark class="peach-lock-hero-mark" />192            </div>193            <span class="peach-lock-eyebrow">vault locked</span>194            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">195              unlock196              <br />197              your vault.198            </h1>199          </div>200201          <section class="peach-lock-panel peach-lock-panel--unlock">202            <label class="peach-label" for="unlock-password">203              master password204            </label>205            <input206              id="unlock-password"207              class="peach-underline-input peach-unlock-input lock-password-input"208              type="password"209              placeholder="••••••••••••"210              autocomplete="current-password"211              value={password()}212              disabled={!props.ready}213              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}214              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}215              onKeyDown={(event) => {216                if (event.key !== "Enter" || !props.ready) return;217                void handleUnlock();218              }}219            />220221            <Show when={error()}>222              <p class="peach-hint peach-hint-error">{error()}</p>223            </Show>224225            <button226              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"227              onClick={() => void handleUnlock()}228              disabled={!props.ready || loading()}229            >230              {loading() ? "unlocking..." : "unlock"}231            </button>232          </section>233        </section>234      </Match>235236      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}237      <Match when={true}>238        <section239          class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"240          data-motion-phase="enter"241        >242          <header class="peach-screen-header lock-screen-header">243            <div class="peach-wordmark-stack">244              <PeachWordmark class="peach-wordmark-wrap" />245              <Show when={props.isPro}>246                <span class="peach-pro-mini-badge">pro</span>247              </Show>248            </div>249          </header>250251          <div class="peach-lock-hero">252            <div class="peach-lock-hero-art" aria-hidden="true">253              <PeachMark class="peach-lock-hero-mark" />254            </div>255            <span class="peach-lock-eyebrow">new vault</span>256            <h1 class="peach-onboarding-headline">257              keep your258              <br />259              secrets.260            </h1>261          </div>262263          <section class="peach-lock-panel peach-lock-panel--primary">264            <div class="peach-lock-label-row">265              <label class="peach-label" for="master-password">266                master password267              </label>268              <button269                class="peach-lock-reveal-btn"270                type="button"271                onClick={() => setShowPassword((value) => !value)}272                aria-label={showPassword() ? "Hide password" : "Show password"}273              >274                <Show275                  when={showPassword()}276                  fallback={277                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">278                      <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" />279                      <circle cx="7" cy="5" r="1.75" />280                    </svg>281                  }282                >283                  <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">284                    <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />285                    <path d="M3.5 5.5L2.5 7.5" />286                    <path d="M7 6.5V8.5" />287                    <path d="M10.5 5.5L11.5 7.5" />288                  </svg>289                </Show>290                {showPassword() ? "hide" : "show"}291              </button>292            </div>293            <input294              id="master-password"295              class="peach-underline-input peach-unlock-input lock-password-input"296              type={showPassword() ? "text" : "password"}297              placeholder="at least 12 characters"298              autocomplete="new-password"299              value={password()}300              disabled={!props.ready}301              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}302              onKeyDown={(event) => {303                if (event.key !== "Enter" || !props.ready) return;304                void handleCreate();305              }}306            />307308            <Show when={password().length > 0 && passwordStrength() !== null}>309              <div class="peach-password-strength">310                <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>311                  <div class="peach-strength-fill"></div>312                </div>313                <span class="peach-strength-label">{passwordStrength()}</span>314              </div>315            </Show>316317            <label class="peach-label" for="confirm-password">318              confirm password319            </label>320            <input321              id="confirm-password"322              class="peach-underline-input peach-unlock-input"323              type={showPassword() ? "text" : "password"}324              placeholder="••••••••••••"325              autocomplete="new-password"326              value={confirmPassword()}327              disabled={!props.ready}328              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}329              onKeyDown={(event) => {330                if (event.key === "Enter" && props.ready) void handleCreate();331              }}332            />333334            <Show when={error()}>335              <p class="peach-hint peach-hint-error">{error()}</p>336            </Show>337338            <button339              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"340              onClick={() => void handleCreate()}341              disabled={!props.ready || loading() || !canCreate()}342            >343              {loading() ? "creating..." : "create vault"}344            </button>345346            <p class="peach-hint peach-hint-error peach-lock-warning">347              If you lose this password, Peach cannot recover your vault. There is no reset.348            </p>349350            <div class="peach-lock-panel-footer">351              <p class="peach-hint">Already have a backup?</p>352              <button353                class="peach-lock-restore-link"354                onClick={() => openRestore("restore")}355                disabled={!props.ready}356              >357                › restore from backup358              </button>359            </div>360          </section>361        </section>362      </Match>363    </Switch>364  );365}366367// ── Restore method picker ──────────────────────────────────────────────────368369function RestorePickerView(props: {370  onBack: () => void;371  onPickCjk: () => void;372  onPickFile: () => void;373  onPickSnapshots: () => void;374}) {375  return (376    <section377      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"378      data-motion-phase="enter"379    >380      <header class="peach-screen-header lock-screen-header">381        <PeachWordmark class="peach-wordmark-wrap" />382        <button class="peach-lock-restore-link" onClick={props.onBack}>383          ‹ back384        </button>385      </header>386387      <div class="peach-lock-hero">388        <span class="peach-lock-eyebrow">restore</span>389        <h1 class="peach-onboarding-headline">390          bring back391          <br />392          your vault.393        </h1>394      </div>395396      <section class="peach-lock-panel peach-lock-panel--restore">397        <p class="peach-hint" style="margin-bottom:2px;">How did you back up your data?</p>398399        <button class="peach-lock-option" onClick={props.onPickCjk}>400          <span class="peach-lock-option-title">backup phrase</span>401          <span class="peach-lock-option-copy">402            A block of Chinese characters exported directly from Peach. You'll need the export passphrase you set at the time.403          </span>404        </button>405406        <button class="peach-lock-option" onClick={props.onPickFile}>407          <span class="peach-lock-option-title">backup file</span>408          <span class="peach-lock-option-copy">409            A Peach JSON export, or a file from Bitwarden, Proton Pass, 1Password, or a browser (Chrome, Safari, Edge CSV).410          </span>411        </button>412413        <button class="peach-lock-option peach-lock-option--history" onClick={props.onPickSnapshots}>414          <span class="peach-lock-option-title">vault history</span>415          <span class="peach-lock-option-copy">416            Peach saves encrypted snapshots locally after each change. Revert to an earlier version if something went wrong.417          </span>418        </button>419      </section>420    </section>421  );422}423424// ── Vault snapshot restore ─────────────────────────────────────────────────425426interface SnapshotSummary {427  index: number;428  timestamp: number;429  entryCount: number;430}431432function SnapshotRestoreView(props: {433  onBack: () => void;434  onRestored: () => void | Promise<void>;435}) {436  const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);437  const [loading, setLoading] = createSignal(true);438  const [selected, setSelected] = createSignal<number | null>(null);439  const [password, setPassword] = createSignal("");440  const [confirming, setConfirming] = createSignal(false);441  const [restoring, setRestoring] = createSignal(false);442  const [error, setError] = createSignal("");443444  onMount(async () => {445    const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });446    setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);447    setLoading(false);448  });449450  function formatAge(ts: number): string {451    const diff = Date.now() - ts;452    const mins = Math.floor(diff / 60_000);453    if (mins < 2) return "just now";454    if (mins < 60) return `${mins} minutes ago`;455    const hours = Math.floor(mins / 60);456    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;457    const days = Math.floor(hours / 24);458    return `${days} day${days === 1 ? "" : "s"} ago`;459  }460461  function selectSnapshot(index: number) {462    setSelected(index);463    setConfirming(false);464    setPassword("");465    setError("");466  }467468  async function handleRestore() {469    const idx = selected();470    if (idx === null || !password()) return;471    setRestoring(true);472    setError("");473    const res = await sendMessage({474      type: "RESTORE_VAULT_SNAPSHOT",475      payload: { index: idx, password: password() },476    });477    if ((res as { ok: boolean }).ok) {478      await props.onRestored();479    } else {480      setError("Incorrect password, or this snapshot cannot be decrypted.");481      setRestoring(false);482    }483  }484485  const selectedSnap = () => {486    const idx = selected();487    return idx !== null ? (snapshots()[idx] ?? null) : null;488  };489490  return (491    <section492      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"493      data-motion-phase="enter"494    >495      <header class="peach-screen-header lock-screen-header">496        <PeachWordmark class="peach-wordmark-wrap" />497        <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>498          ‹ back499        </button>500      </header>501502      <div class="peach-lock-hero">503        <span class="peach-lock-eyebrow">vault history</span>504        <h1 class="peach-onboarding-headline">505          revert to506          <br />507          a snapshot.508        </h1>509      </div>510511      <section class="peach-lock-panel peach-snapshot-panel">512        <Show when={loading()}>513          <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>514        </Show>515516        <Show when={!loading() && snapshots().length === 0}>517          <p class="peach-hint" style="line-height:1.6;">518            No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.519          </p>520        </Show>521522        <Show when={!loading() && snapshots().length > 0}>523          <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>524          <div class="peach-snapshot-list">525            <For each={snapshots()}>526              {(snap) => (527                <button528                  class="peach-snapshot-row"529                  classList={{ "is-selected": selected() === snap.index }}530                  onClick={() => selectSnapshot(snap.index)}531                  disabled={restoring()}532                >533                  <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>534                  <span class="peach-snapshot-count">535                    {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}536                  </span>537                </button>538              )}539            </For>540          </div>541542          <Show when={selected() !== null}>543            <div class="peach-snapshot-confirm">544              <label class="peach-label" for="snapshot-pw">master password</label>545              <input546                id="snapshot-pw"547                class="peach-underline-input peach-unlock-input"548                type="password"549                placeholder="••••••••••••"550                autocomplete="current-password"551                value={password()}552                disabled={restoring()}553                onInput={(e) => setPassword((e.target as HTMLInputElement).value)}554                onKeyDown={(e) => {555                  if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();556                }}557              />558559              <Show when={error()}>560                <p class="peach-hint peach-hint-error">{error()}</p>561              </Show>562563              <Show564                when={confirming()}565                fallback={566                  <button567                    class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"568                    onClick={() => setConfirming(true)}569                    disabled={!password().trim() || restoring()}570                  >571                    restore this version →572                  </button>573                }574              >575                <div class="peach-snapshot-warning">576                  <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">577                    <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />578                    <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />579                  </svg>580                  This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.581                </div>582                <div class="peach-snapshot-confirm-row">583                  <button584                    class="peach-btn peach-btn--accent peach-btn--sm"585                    style="flex:1"586                    onClick={() => void handleRestore()}587                    disabled={restoring() || !password().trim()}588                  >589                    {restoring() ? "restoring…" : "yes, restore"}590                  </button>591                  <button592                    class="peach-btn peach-btn--ghost peach-btn--sm"593                    onClick={() => setConfirming(false)}594                    disabled={restoring()}595                  >596                    cancel597                  </button>598                </div>599              </Show>600            </div>601          </Show>602        </Show>603      </section>604    </section>605  );606}607608// ── Helpers ────────────────────────────────────────────────────────────────609610function getDisplayError(error: string | undefined, fallback: string): string {611  if (!error) return fallback;612  if (613    error === "SERVICE_WORKER_UNAVAILABLE" ||614    error.includes("Receiving end does not exist") ||615    error.includes("message port closed")616  ) {617    return "background service unavailable";618  }619  return error;620}