Lock.tsx

22.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 { ImportView } from "./Import";67// Lazy-load zxcvbn — it's heavy (~800 kB) and only needed during vault creation8let _zxcvbn: ((password: string) => { score: number }) | null = null;9async function getZxcvbn() {10  if (_zxcvbn) return _zxcvbn;11  const mod = await import("zxcvbn");12  _zxcvbn = mod.default;13  return _zxcvbn;14}1516interface LockProps {17  hasVault: boolean;18  ready: boolean;19  isPro?: boolean;20  onUnlock: () => void | Promise<void>;21}2223// "restore" = picker, "snapshots" = vault history, "file" = detail flow24type RestoreSurface = "restore" | "snapshots" | "file" | null;2526export function LockView(props: LockProps) {27  const [password, setPassword] = createSignal("");28  const [confirmPassword, setConfirmPassword] = createSignal("");29  const [error, setError] = createSignal("");30  const [loading, setLoading] = createSignal(false);31  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);32  const [showPassword, setShowPassword] = createSignal(false);3334  const MIN_LENGTH = 12;35  const MIN_ZXCVBN_SCORE = 2;3637  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);3839  const canCreate = createMemo(40    () =>41      password().length >= MIN_LENGTH &&42      confirmPassword().length >= MIN_LENGTH &&43      password() === confirmPassword() &&44      strengthScore() !== null &&45      strengthScore()! >= MIN_ZXCVBN_SCORE46  );4748  const passwordStrength = createMemo(() => {49    const score = strengthScore();50    if (score === null) return null;51    if (score <= 1) return "weak";52    if (score === 2) return "fair";53    if (score === 3) return "good";54    return "strong";55  });5657  // Update strength asynchronously when password changes58  createMemo(() => {59    const value = password();60    if (!value) { setStrengthScore(null); return; }61    getZxcvbn().then((zxcvbn) => {62      if (password() === value) setStrengthScore(zxcvbn(value).score);63    }).catch(() => {64      // zxcvbn unavailable — fall back to length-only heuristic65      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);66    });67  });6869  async function handleUnlock() {70    if (!password().trim()) return;71    setLoading(true);72    setError("");73    const response = await sendMessage({74      type: "UNLOCK",75      payload: { password: password() },76    });77    const result = response as { ok: boolean; error?: string };78    if (result.ok) {79      await props.onUnlock();80      setLoading(false);81      return;82    }83    setError(getDisplayError(result.error, "incorrect password"));84    setPassword("");85    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;86    passwordField?.classList.add("shake");87    setTimeout(() => passwordField?.classList.remove("shake"), 300);88    setLoading(false);89  }9091  async function handleCreate() {92    if (!password().trim()) return;93    if (password().length < MIN_LENGTH) {94      setError(`password must be at least ${MIN_LENGTH} characters`);95      return;96    }97    if (password() !== confirmPassword()) {98      setError("passwords do not match");99      return;100    }101    const score = strengthScore();102    if (score === null || score < MIN_ZXCVBN_SCORE) {103      setError("password is too weak — try a longer or more varied passphrase");104      return;105    }106    setLoading(true);107    setError("");108    const response = await sendMessage({109      type: "CREATE_VAULT",110      payload: { password: password() },111    });112    const result = response as { ok: boolean; error?: string };113    if (result.ok) {114      await props.onUnlock();115      setLoading(false);116      return;117    }118    setError(getDisplayError(result.error, "failed to create vault"));119    setLoading(false);120  }121122  function openRestore(surface: Exclude<RestoreSurface, null>) {123    if (!props.ready || loading()) return;124    setPassword("");125    setConfirmPassword("");126    setError("");127    setRestoreSurface(surface);128  }129130  // ── All surfaces routed reactively via Switch/Match ───────────────────131  return (132    <Switch>133      {/* ── Restore picker ────────────────────────────────────────── */}134      <Match when={restoreSurface() === "restore"}>135        <RestorePickerView136          onBack={() => setRestoreSurface(null)}137          onPickFile={() => setRestoreSurface("file")}138          onPickSnapshots={() => setRestoreSurface("snapshots")}139        />140      </Match>141142      {/* ── Vault snapshot history ────────────────────────────────── */}143      <Match when={restoreSurface() === "snapshots"}>144        <SnapshotRestoreView145          onBack={() => setRestoreSurface("restore")}146          onRestored={props.onUnlock}147        />148      </Match>149150      {/* ── File restore ──────────────────────────────────────────── */}151      <Match when={restoreSurface() === "file"}>152        <ImportView153          mode="onboarding"154          replacesExistingVault={props.hasVault}155          onBack={() => setRestoreSurface("restore")}156          onImport={async () => {157            setRestoreSurface(null);158            await props.onUnlock();159          }}160        />161      </Match>162163      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}164      <Match when={props.hasVault}>165        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">166          <header class="peach-screen-header lock-screen-header">167            <div class="peach-wordmark-stack">168              <PeachWordmark class="peach-wordmark-wrap" />169              <Show when={props.isPro}>170                <span class="peach-pro-mini-badge">pro</span>171              </Show>172            </div>173          </header>174175          <div class="peach-lock-hero peach-lock-hero--unlock">176            <div class="peach-lock-hero-art" aria-hidden="true">177              <PeachMark class="peach-lock-hero-mark" />178            </div>179            <span class="peach-lock-eyebrow">vault locked</span>180            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">181              unlock182              <br />183              your vault.184            </h1>185          </div>186187          <section class="peach-lock-panel peach-lock-panel--unlock">188            <label class="peach-label" for="unlock-password">189              master password190            </label>191            <input192              id="unlock-password"193              class="peach-underline-input peach-unlock-input lock-password-input"194              type="password"195              placeholder="••••••••••••"196              autocomplete="current-password"197              value={password()}198              disabled={!props.ready}199              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}200              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}201              onKeyDown={(event) => {202                if (event.key !== "Enter" || !props.ready) return;203                void handleUnlock();204              }}205            />206207            <Show when={error()}>208              <p class="peach-hint peach-hint-error">{error()}</p>209            </Show>210211            <button212              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"213              onClick={() => void handleUnlock()}214              disabled={!props.ready || loading()}215            >216              {loading() ? "unlocking..." : "unlock"}217            </button>218          </section>219        </section>220      </Match>221222      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}223      <Match when={true}>224        <section225          class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"226          data-motion-phase="enter"227        >228          <header class="peach-screen-header lock-screen-header">229            <div class="peach-wordmark-stack">230              <PeachWordmark class="peach-wordmark-wrap" />231              <Show when={props.isPro}>232                <span class="peach-pro-mini-badge">pro</span>233              </Show>234            </div>235          </header>236237          <div class="peach-lock-hero">238            <div class="peach-lock-hero-art" aria-hidden="true">239              <PeachMark class="peach-lock-hero-mark" />240            </div>241            <span class="peach-lock-eyebrow">new vault</span>242            <h1 class="peach-onboarding-headline">243              keep your244              <br />245              secrets.246            </h1>247          </div>248249          <section class="peach-lock-panel peach-lock-panel--primary">250            <div class="peach-lock-label-row">251              <label class="peach-label" for="master-password">252                master password253              </label>254              <button255                class="peach-lock-reveal-btn"256                type="button"257                onClick={() => setShowPassword((value) => !value)}258                aria-label={showPassword() ? "Hide password" : "Show password"}259              >260                <Show261                  when={showPassword()}262                  fallback={263                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">264                      <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" />265                      <circle cx="7" cy="5" r="1.75" />266                    </svg>267                  }268                >269                  <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">270                    <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />271                    <path d="M3.5 5.5L2.5 7.5" />272                    <path d="M7 6.5V8.5" />273                    <path d="M10.5 5.5L11.5 7.5" />274                  </svg>275                </Show>276                {showPassword() ? "hide" : "show"}277              </button>278            </div>279            <input280              id="master-password"281              class="peach-underline-input peach-unlock-input lock-password-input"282              type={showPassword() ? "text" : "password"}283              placeholder="at least 12 characters"284              autocomplete="new-password"285              value={password()}286              disabled={!props.ready}287              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}288              onKeyDown={(event) => {289                if (event.key !== "Enter" || !props.ready) return;290                void handleCreate();291              }}292            />293294            <Show when={password().length > 0 && passwordStrength() !== null}>295              <div class="peach-password-strength">296                <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>297                  <div class="peach-strength-fill"></div>298                </div>299                <span class="peach-strength-label">{passwordStrength()}</span>300              </div>301            </Show>302303            <label class="peach-label" for="confirm-password">304              confirm password305            </label>306            <input307              id="confirm-password"308              class="peach-underline-input peach-unlock-input"309              type={showPassword() ? "text" : "password"}310              placeholder="••••••••••••"311              autocomplete="new-password"312              value={confirmPassword()}313              disabled={!props.ready}314              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}315              onKeyDown={(event) => {316                if (event.key === "Enter" && props.ready) void handleCreate();317              }}318            />319320            <Show when={error()}>321              <p class="peach-hint peach-hint-error">{error()}</p>322            </Show>323324            <button325              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"326              onClick={() => void handleCreate()}327              disabled={!props.ready || loading() || !canCreate()}328            >329              {loading() ? "creating..." : "create vault"}330            </button>331332            <p class="peach-hint peach-hint-error peach-lock-warning">333              If you lose this password, Peach cannot recover your vault. There is no reset.334            </p>335336            <div class="peach-lock-panel-footer">337              <p class="peach-hint">Already have a backup?</p>338              <button339                class="peach-lock-restore-link"340                onClick={() => openRestore("restore")}341                disabled={!props.ready}342              >343                › restore from backup344              </button>345            </div>346          </section>347        </section>348      </Match>349    </Switch>350  );351}352353// ── Restore method picker ──────────────────────────────────────────────────354355function RestorePickerView(props: {356  onBack: () => void;357  onPickFile: () => void;358  onPickSnapshots: () => void;359}) {360  return (361    <section362      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"363      data-motion-phase="enter"364    >365      <header class="peach-screen-header lock-screen-header">366        <PeachWordmark class="peach-wordmark-wrap" />367        <button class="peach-lock-restore-link" onClick={props.onBack}>368          ‹ back369        </button>370      </header>371372      <div class="peach-lock-hero">373        <span class="peach-lock-eyebrow">restore</span>374        <h1 class="peach-onboarding-headline">375          bring back376          <br />377          your vault.378        </h1>379      </div>380381      <section class="peach-lock-panel peach-lock-panel--restore">382        <p class="peach-hint" style="margin-bottom:2px;">How did you back up your data?</p>383384        <button class="peach-lock-option" onClick={props.onPickFile}>385          <span class="peach-lock-option-title">backup file</span>386          <span class="peach-lock-option-copy">387            A Peach JSON export, or a file from Bitwarden, Proton Pass, 1Password, or a browser (Chrome, Safari, Edge CSV).388          </span>389        </button>390391        <button class="peach-lock-option peach-lock-option--history" onClick={props.onPickSnapshots}>392          <span class="peach-lock-option-title">vault history</span>393          <span class="peach-lock-option-copy">394            Peach saves encrypted snapshots locally after each change. Revert to an earlier version if something went wrong.395          </span>396        </button>397      </section>398    </section>399  );400}401402// ── Vault snapshot restore ─────────────────────────────────────────────────403404interface SnapshotSummary {405  index: number;406  timestamp: number;407  entryCount: number;408}409410function SnapshotRestoreView(props: {411  onBack: () => void;412  onRestored: () => void | Promise<void>;413}) {414  const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);415  const [loading, setLoading] = createSignal(true);416  const [selected, setSelected] = createSignal<number | null>(null);417  const [password, setPassword] = createSignal("");418  const [confirming, setConfirming] = createSignal(false);419  const [restoring, setRestoring] = createSignal(false);420  const [error, setError] = createSignal("");421422  onMount(async () => {423    const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });424    setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);425    setLoading(false);426  });427428  function formatAge(ts: number): string {429    const diff = Date.now() - ts;430    const mins = Math.floor(diff / 60_000);431    if (mins < 2) return "just now";432    if (mins < 60) return `${mins} minutes ago`;433    const hours = Math.floor(mins / 60);434    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;435    const days = Math.floor(hours / 24);436    return `${days} day${days === 1 ? "" : "s"} ago`;437  }438439  function selectSnapshot(index: number) {440    setSelected(index);441    setConfirming(false);442    setPassword("");443    setError("");444  }445446  async function handleRestore() {447    const idx = selected();448    if (idx === null || !password()) return;449    setRestoring(true);450    setError("");451    const res = await sendMessage({452      type: "RESTORE_VAULT_SNAPSHOT",453      payload: { index: idx, password: password() },454    });455    if ((res as { ok: boolean }).ok) {456      await props.onRestored();457    } else {458      setError("Incorrect password, or this snapshot cannot be decrypted.");459      setRestoring(false);460    }461  }462463  const selectedSnap = () => {464    const idx = selected();465    return idx !== null ? (snapshots()[idx] ?? null) : null;466  };467468  return (469    <section470      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"471      data-motion-phase="enter"472    >473      <header class="peach-screen-header lock-screen-header">474        <PeachWordmark class="peach-wordmark-wrap" />475        <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>476          ‹ back477        </button>478      </header>479480      <div class="peach-lock-hero">481        <span class="peach-lock-eyebrow">vault history</span>482        <h1 class="peach-onboarding-headline">483          revert to484          <br />485          a snapshot.486        </h1>487      </div>488489      <section class="peach-lock-panel peach-snapshot-panel">490        <Show when={loading()}>491          <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>492        </Show>493494        <Show when={!loading() && snapshots().length === 0}>495          <p class="peach-hint" style="line-height:1.6;">496            No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.497          </p>498        </Show>499500        <Show when={!loading() && snapshots().length > 0}>501          <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>502          <div class="peach-snapshot-list">503            <For each={snapshots()}>504              {(snap) => (505                <button506                  class="peach-snapshot-row"507                  classList={{ "is-selected": selected() === snap.index }}508                  onClick={() => selectSnapshot(snap.index)}509                  disabled={restoring()}510                >511                  <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>512                  <span class="peach-snapshot-count">513                    {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}514                  </span>515                </button>516              )}517            </For>518          </div>519520          <Show when={selected() !== null}>521            <div class="peach-snapshot-confirm">522              <label class="peach-label" for="snapshot-pw">master password</label>523              <input524                id="snapshot-pw"525                class="peach-underline-input peach-unlock-input"526                type="password"527                placeholder="••••••••••••"528                autocomplete="current-password"529                value={password()}530                disabled={restoring()}531                onInput={(e) => setPassword((e.target as HTMLInputElement).value)}532                onKeyDown={(e) => {533                  if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();534                }}535              />536537              <Show when={error()}>538                <p class="peach-hint peach-hint-error">{error()}</p>539              </Show>540541              <Show542                when={confirming()}543                fallback={544                  <button545                    class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"546                    onClick={() => setConfirming(true)}547                    disabled={!password().trim() || restoring()}548                  >549                    restore this version →550                  </button>551                }552              >553                <div class="peach-snapshot-warning">554                  <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">555                    <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />556                    <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />557                  </svg>558                  This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.559                </div>560                <div class="peach-snapshot-confirm-row">561                  <button562                    class="peach-btn peach-btn--accent peach-btn--sm"563                    style="flex:1"564                    onClick={() => void handleRestore()}565                    disabled={restoring() || !password().trim()}566                  >567                    {restoring() ? "restoring…" : "yes, restore"}568                  </button>569                  <button570                    class="peach-btn peach-btn--ghost peach-btn--sm"571                    onClick={() => setConfirming(false)}572                    disabled={restoring()}573                  >574                    cancel575                  </button>576                </div>577              </Show>578            </div>579          </Show>580        </Show>581      </section>582    </section>583  );584}585586// ── Helpers ────────────────────────────────────────────────────────────────587588function getDisplayError(error: string | undefined, fallback: string): string {589  if (!error) return fallback;590  if (591    error === "SERVICE_WORKER_UNAVAILABLE" ||592    error.includes("Receiving end does not exist") ||593    error.includes("message port closed")594  ) {595    return "background service unavailable";596  }597  return error;598}