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          <LockRuneCanvas />184          <header class="peach-screen-header lock-screen-header">185            <div class="peach-wordmark-stack">186              <PeachWordmark class="peach-wordmark-wrap" />187              <Show when={props.isPro}>188                <span class="peach-pro-mini-badge">pro</span>189              </Show>190            </div>191          </header>192193          <div class="peach-lock-hero peach-lock-hero--unlock">194            <div class="peach-lock-hero-art" aria-hidden="true">195              <PeachMark class="peach-lock-hero-mark" />196            </div>197            <span class="peach-lock-eyebrow">vault locked</span>198            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">199              unlock200              <br />201              your vault.202            </h1>203          </div>204205          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">206            <label class="peach-label" for="unlock-password">207              master password208            </label>209            <input210              id="unlock-password"211              class="peach-underline-input peach-unlock-input lock-password-input"212              type="password"213              placeholder="••••••••••••"214              autocomplete="current-password"215              value={password()}216              disabled={!props.ready}217              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}218              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}219              onKeyDown={(event) => {220                if (event.key !== "Enter" || !props.ready) return;221                void handleUnlock();222              }}223            />224225            <Show when={error()}>226              <p class="peach-hint peach-hint-error">{error()}</p>227            </Show>228229            <button230              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"231              onClick={() => void handleUnlock()}232              disabled={!props.ready || loading()}233            >234              {loading() ? "unlocking..." : "unlock"}235            </button>236          </section>237        </section>238      </Match>239240      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}241      <Match when={true}>242        <section243          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"244          data-motion-phase="enter"245        >246          <header class="peach-screen-header peach-newvault-header">247            <div class="peach-wordmark-stack">248              <PeachWordmark class="peach-wordmark-wrap" />249              <Show when={props.isPro}>250                <span class="peach-pro-mini-badge">pro</span>251              </Show>252            </div>253          </header>254255          <div class="peach-newvault-mark" aria-hidden="true">256            <PeachMark class="peach-newvault-mark-art" />257          </div>258259          <div class="peach-newvault-card">260            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>261            <hr class="peach-newvault-divider" />262263            <div class="peach-newvault-field">264              <div class="peach-newvault-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-newvault-input lock-password-input"296                type={showPassword() ? "text" : "password"}297                placeholder="••••••••••••"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              />307              <Show308                when={password().length > 0 && passwordStrength() !== null}309                fallback={310                  <p class="peach-newvault-hint">at least 12 characters</p>311                }312              >313                <div class="peach-password-strength">314                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>315                    <div class="peach-strength-fill"></div>316                  </div>317                  <span class="peach-strength-label">{passwordStrength()}</span>318                </div>319              </Show>320            </div>321322            <div class="peach-newvault-field">323              <label class="peach-label" for="confirm-password">324                confirm password325              </label>326              <input327                id="confirm-password"328                class="peach-underline-input peach-newvault-input"329                type={showPassword() ? "text" : "password"}330                placeholder="••••••••••••"331                autocomplete="new-password"332                value={confirmPassword()}333                disabled={!props.ready}334                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}335                onKeyDown={(event) => {336                  if (event.key === "Enter" && props.ready) void handleCreate();337                }}338              />339            </div>340341            <Show when={error()}>342              <p class="peach-hint peach-hint-error">{error()}</p>343            </Show>344345            <button346              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"347              onClick={() => void handleCreate()}348              disabled={!props.ready || loading() || !canCreate()}349            >350              {loading() ? "creating..." : "create vault"}351            </button>352353            <p class="peach-newvault-warning">354              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>355              <span>If you forget it, your vault cannot be opened. There is no reset.</span>356            </p>357          </div>358359          <div class="peach-newvault-footer">360            <p class="peach-hint">Already have a backup?</p>361            <button362              class="peach-lock-restore-link"363              onClick={() => openRestore("restore")}364              disabled={!props.ready}365            >366              › restore from backup367            </button>368          </div>369        </section>370      </Match>371    </Switch>372  );373}374375// ── Restore method picker ──────────────────────────────────────────────────376377function RestorePickerView(props: {378  onBack: () => void;379  onPickPeachScript: () => void;380  onPickFile: () => void;381  onPickSnapshots: () => void;382}) {383  return (384    <section385      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"386      data-motion-phase="enter"387    >388      <header class="peach-screen-header lock-screen-header">389        <PeachWordmark class="peach-wordmark-wrap" />390        <button class="peach-lock-restore-link" onClick={props.onBack}>391          ‹ back392        </button>393      </header>394395      <div class="peach-lock-hero">396        <span class="peach-lock-eyebrow">restore</span>397        <h1 class="peach-onboarding-headline">398          bring back399          <br />400          your vault.401        </h1>402      </div>403404      <section class="peach-lock-panel peach-lock-panel--restore">405        <p class="peach-hint" style="margin-bottom:2px;">How did you back up your data?</p>406407        <button class="peach-lock-option" onClick={props.onPickPeachScript}>408          <span class="peach-lock-option-title">peachscript</span>409          <span class="peach-lock-option-copy">410            A string of Peach symbols you exported from another Peach device. You'll need the backup password you set at the time.411          </span>412        </button>413414        <button class="peach-lock-option" onClick={props.onPickFile}>415          <span class="peach-lock-option-title">backup file</span>416          <span class="peach-lock-option-copy">417            A Peach JSON export, or a file from Bitwarden, Proton Pass, 1Password, or a browser (Chrome, Safari, Edge CSV).418          </span>419        </button>420421        <button class="peach-lock-option peach-lock-option--history" onClick={props.onPickSnapshots}>422          <span class="peach-lock-option-title">vault history</span>423          <span class="peach-lock-option-copy">424            Peach saves encrypted snapshots locally after each change. Revert to an earlier version if something went wrong.425          </span>426        </button>427      </section>428    </section>429  );430}431432// ── Vault snapshot restore ─────────────────────────────────────────────────433434interface SnapshotSummary {435  index: number;436  timestamp: number;437  entryCount: number;438}439440function SnapshotRestoreView(props: {441  onBack: () => void;442  onRestored: () => void | Promise<void>;443}) {444  const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);445  const [loading, setLoading] = createSignal(true);446  const [selected, setSelected] = createSignal<number | null>(null);447  const [password, setPassword] = createSignal("");448  const [confirming, setConfirming] = createSignal(false);449  const [restoring, setRestoring] = createSignal(false);450  const [error, setError] = createSignal("");451452  onMount(async () => {453    const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });454    setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);455    setLoading(false);456  });457458  function formatAge(ts: number): string {459    const diff = Date.now() - ts;460    const mins = Math.floor(diff / 60_000);461    if (mins < 2) return "just now";462    if (mins < 60) return `${mins} minutes ago`;463    const hours = Math.floor(mins / 60);464    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;465    const days = Math.floor(hours / 24);466    return `${days} day${days === 1 ? "" : "s"} ago`;467  }468469  function selectSnapshot(index: number) {470    setSelected(index);471    setConfirming(false);472    setPassword("");473    setError("");474  }475476  async function handleRestore() {477    const idx = selected();478    if (idx === null || !password()) return;479    setRestoring(true);480    setError("");481    const res = await sendMessage({482      type: "RESTORE_VAULT_SNAPSHOT",483      payload: { index: idx, password: password() },484    });485    if ((res as { ok: boolean }).ok) {486      await props.onRestored();487    } else {488      setError("Incorrect password, or this snapshot cannot be decrypted.");489      setRestoring(false);490    }491  }492493  const selectedSnap = () => {494    const idx = selected();495    return idx !== null ? (snapshots()[idx] ?? null) : null;496  };497498  return (499    <section500      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"501      data-motion-phase="enter"502    >503      <header class="peach-screen-header lock-screen-header">504        <PeachWordmark class="peach-wordmark-wrap" />505        <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>506          ‹ back507        </button>508      </header>509510      <div class="peach-lock-hero">511        <span class="peach-lock-eyebrow">vault history</span>512        <h1 class="peach-onboarding-headline">513          revert to514          <br />515          a snapshot.516        </h1>517      </div>518519      <section class="peach-lock-panel peach-snapshot-panel">520        <Show when={loading()}>521          <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>522        </Show>523524        <Show when={!loading() && snapshots().length === 0}>525          <p class="peach-hint" style="line-height:1.6;">526            No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.527          </p>528        </Show>529530        <Show when={!loading() && snapshots().length > 0}>531          <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>532          <div class="peach-snapshot-list">533            <For each={snapshots()}>534              {(snap) => (535                <button536                  class="peach-snapshot-row"537                  classList={{ "is-selected": selected() === snap.index }}538                  onClick={() => selectSnapshot(snap.index)}539                  disabled={restoring()}540                >541                  <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>542                  <span class="peach-snapshot-count">543                    {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}544                  </span>545                </button>546              )}547            </For>548          </div>549550          <Show when={selected() !== null}>551            <div class="peach-snapshot-confirm">552              <label class="peach-label" for="snapshot-pw">master password</label>553              <input554                id="snapshot-pw"555                class="peach-underline-input peach-unlock-input"556                type="password"557                placeholder="••••••••••••"558                autocomplete="current-password"559                value={password()}560                disabled={restoring()}561                onInput={(e) => setPassword((e.target as HTMLInputElement).value)}562                onKeyDown={(e) => {563                  if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();564                }}565              />566567              <Show when={error()}>568                <p class="peach-hint peach-hint-error">{error()}</p>569              </Show>570571              <Show572                when={confirming()}573                fallback={574                  <button575                    class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"576                    onClick={() => setConfirming(true)}577                    disabled={!password().trim() || restoring()}578                  >579                    restore this version →580                  </button>581                }582              >583                <div class="peach-snapshot-warning">584                  <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">585                    <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />586                    <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />587                  </svg>588                  This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.589                </div>590                <div class="peach-snapshot-confirm-row">591                  <button592                    class="peach-btn peach-btn--accent peach-btn--sm"593                    style="flex:1"594                    onClick={() => void handleRestore()}595                    disabled={restoring() || !password().trim()}596                  >597                    {restoring() ? "restoring…" : "yes, restore"}598                  </button>599                  <button600                    class="peach-btn peach-btn--ghost peach-btn--sm"601                    onClick={() => setConfirming(false)}602                    disabled={restoring()}603                  >604                    cancel605                  </button>606                </div>607              </Show>608            </div>609          </Show>610        </Show>611      </section>612    </section>613  );614}615616// ── Helpers ────────────────────────────────────────────────────────────────617618function getDisplayError(error: string | undefined, fallback: string): string {619  if (!error) return fallback;620  if (621    error === "SERVICE_WORKER_UNAVAILABLE" ||622    error.includes("Receiving end does not exist") ||623    error.includes("message port closed")624  ) {625    return "background service unavailable";626  }627  return error;628}