Lock.tsx

23.6 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, "peachscript" / "file" = detail flows24type RestoreSurface = "restore" | "snapshots" | "peachscript" | "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          onPickPeachScript={() => setRestoreSurface("peachscript")}138          onPickFile={() => setRestoreSurface("file")}139          onPickSnapshots={() => setRestoreSurface("snapshots")}140        />141      </Match>142143      {/* ── Vault snapshot history ────────────────────────────────── */}144      <Match when={restoreSurface() === "snapshots"}>145        <SnapshotRestoreView146          onBack={() => setRestoreSurface("restore")}147          onRestored={props.onUnlock}148        />149      </Match>150151      {/* ── PeachScript restore ───────────────────────────────────── */}152      <Match when={restoreSurface() === "peachscript"}>153        <ImportView154          mode="onboarding"155          replacesExistingVault={props.hasVault}156          initialView="peachscript"157          onBack={() => setRestoreSurface("restore")}158          onImport={async () => {159            setRestoreSurface(null);160            await props.onUnlock();161          }}162        />163      </Match>164165      {/* ── File restore ──────────────────────────────────────────── */}166      <Match when={restoreSurface() === "file"}>167        <ImportView168          mode="onboarding"169          replacesExistingVault={props.hasVault}170          initialView="file"171          onBack={() => setRestoreSurface("restore")}172          onImport={async () => {173            setRestoreSurface(null);174            await props.onUnlock();175          }}176        />177      </Match>178179      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}180      <Match when={props.hasVault}>181        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">182          <header class="peach-screen-header lock-screen-header">183            <div class="peach-wordmark-stack">184              <PeachWordmark class="peach-wordmark-wrap" />185              <Show when={props.isPro}>186                <span class="peach-pro-mini-badge">pro</span>187              </Show>188            </div>189          </header>190191          <div class="peach-lock-hero peach-lock-hero--unlock">192            <div class="peach-lock-hero-art" aria-hidden="true">193              <PeachMark class="peach-lock-hero-mark" />194            </div>195            <span class="peach-lock-eyebrow">vault locked</span>196            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">197              unlock198              <br />199              your vault.200            </h1>201          </div>202203          <section class="peach-lock-panel peach-lock-panel--unlock">204            <label class="peach-label" for="unlock-password">205              master password206            </label>207            <input208              id="unlock-password"209              class="peach-underline-input peach-unlock-input lock-password-input"210              type="password"211              placeholder="••••••••••••"212              autocomplete="current-password"213              value={password()}214              disabled={!props.ready}215              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}216              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}217              onKeyDown={(event) => {218                if (event.key !== "Enter" || !props.ready) return;219                void handleUnlock();220              }}221            />222223            <Show when={error()}>224              <p class="peach-hint peach-hint-error">{error()}</p>225            </Show>226227            <button228              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"229              onClick={() => void handleUnlock()}230              disabled={!props.ready || loading()}231            >232              {loading() ? "unlocking..." : "unlock"}233            </button>234          </section>235        </section>236      </Match>237238      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}239      <Match when={true}>240        <section241          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"242          data-motion-phase="enter"243        >244          <header class="peach-screen-header peach-newvault-header">245            <div class="peach-wordmark-stack">246              <PeachWordmark class="peach-wordmark-wrap" />247              <Show when={props.isPro}>248                <span class="peach-pro-mini-badge">pro</span>249              </Show>250            </div>251          </header>252253          <div class="peach-newvault-mark" aria-hidden="true">254            <PeachMark class="peach-newvault-mark-art" />255          </div>256257          <div class="peach-newvault-card">258            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>259            <hr class="peach-newvault-divider" />260261            <div class="peach-newvault-field">262              <div class="peach-newvault-label-row">263                <label class="peach-label" for="master-password">264                  master password265                </label>266                <button267                  class="peach-lock-reveal-btn"268                  type="button"269                  onClick={() => setShowPassword((value) => !value)}270                  aria-label={showPassword() ? "Hide password" : "Show password"}271                >272                  <Show273                    when={showPassword()}274                    fallback={275                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">276                        <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" />277                        <circle cx="7" cy="5" r="1.75" />278                      </svg>279                    }280                  >281                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">282                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />283                      <path d="M3.5 5.5L2.5 7.5" />284                      <path d="M7 6.5V8.5" />285                      <path d="M10.5 5.5L11.5 7.5" />286                    </svg>287                  </Show>288                  {showPassword() ? "hide" : "show"}289                </button>290              </div>291              <input292                id="master-password"293                class="peach-underline-input peach-newvault-input lock-password-input"294                type={showPassword() ? "text" : "password"}295                placeholder="••••••••••••"296                autocomplete="new-password"297                value={password()}298                disabled={!props.ready}299                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}300                onKeyDown={(event) => {301                  if (event.key !== "Enter" || !props.ready) return;302                  void handleCreate();303                }}304              />305              <Show306                when={password().length > 0 && passwordStrength() !== null}307                fallback={308                  <p class="peach-newvault-hint">at least 12 characters</p>309                }310              >311                <div class="peach-password-strength">312                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>313                    <div class="peach-strength-fill"></div>314                  </div>315                  <span class="peach-strength-label">{passwordStrength()}</span>316                </div>317              </Show>318            </div>319320            <div class="peach-newvault-field">321              <label class="peach-label" for="confirm-password">322                confirm password323              </label>324              <input325                id="confirm-password"326                class="peach-underline-input peach-newvault-input"327                type={showPassword() ? "text" : "password"}328                placeholder="••••••••••••"329                autocomplete="new-password"330                value={confirmPassword()}331                disabled={!props.ready}332                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}333                onKeyDown={(event) => {334                  if (event.key === "Enter" && props.ready) void handleCreate();335                }}336              />337            </div>338339            <Show when={error()}>340              <p class="peach-hint peach-hint-error">{error()}</p>341            </Show>342343            <button344              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"345              onClick={() => void handleCreate()}346              disabled={!props.ready || loading() || !canCreate()}347            >348              {loading() ? "creating..." : "create vault"}349            </button>350351            <p class="peach-newvault-warning">352              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>353              <span>If you forget it, your vault cannot be opened. There is no reset.</span>354            </p>355          </div>356357          <div class="peach-newvault-footer">358            <p class="peach-hint">Already have a backup?</p>359            <button360              class="peach-lock-restore-link"361              onClick={() => openRestore("restore")}362              disabled={!props.ready}363            >364              › restore from backup365            </button>366          </div>367        </section>368      </Match>369    </Switch>370  );371}372373// ── Restore method picker ──────────────────────────────────────────────────374375function RestorePickerView(props: {376  onBack: () => void;377  onPickPeachScript: () => void;378  onPickFile: () => void;379  onPickSnapshots: () => void;380}) {381  return (382    <section383      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"384      data-motion-phase="enter"385    >386      <header class="peach-screen-header lock-screen-header">387        <PeachWordmark class="peach-wordmark-wrap" />388        <button class="peach-lock-restore-link" onClick={props.onBack}>389          ‹ back390        </button>391      </header>392393      <div class="peach-lock-hero">394        <span class="peach-lock-eyebrow">restore</span>395        <h1 class="peach-onboarding-headline">396          bring back397          <br />398          your vault.399        </h1>400      </div>401402      <section class="peach-lock-panel peach-lock-panel--restore">403        <p class="peach-hint" style="margin-bottom:2px;">How did you back up your data?</p>404405        <button class="peach-lock-option" onClick={props.onPickPeachScript}>406          <span class="peach-lock-option-title">peachscript</span>407          <span class="peach-lock-option-copy">408            A string of Peach symbols you exported from another Peach device. You'll need the backup password you set at the time.409          </span>410        </button>411412        <button class="peach-lock-option" onClick={props.onPickFile}>413          <span class="peach-lock-option-title">backup file</span>414          <span class="peach-lock-option-copy">415            A Peach JSON export, or a file from Bitwarden, Proton Pass, 1Password, or a browser (Chrome, Safari, Edge CSV).416          </span>417        </button>418419        <button class="peach-lock-option peach-lock-option--history" onClick={props.onPickSnapshots}>420          <span class="peach-lock-option-title">vault history</span>421          <span class="peach-lock-option-copy">422            Peach saves encrypted snapshots locally after each change. Revert to an earlier version if something went wrong.423          </span>424        </button>425      </section>426    </section>427  );428}429430// ── Vault snapshot restore ─────────────────────────────────────────────────431432interface SnapshotSummary {433  index: number;434  timestamp: number;435  entryCount: number;436}437438function SnapshotRestoreView(props: {439  onBack: () => void;440  onRestored: () => void | Promise<void>;441}) {442  const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);443  const [loading, setLoading] = createSignal(true);444  const [selected, setSelected] = createSignal<number | null>(null);445  const [password, setPassword] = createSignal("");446  const [confirming, setConfirming] = createSignal(false);447  const [restoring, setRestoring] = createSignal(false);448  const [error, setError] = createSignal("");449450  onMount(async () => {451    const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });452    setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);453    setLoading(false);454  });455456  function formatAge(ts: number): string {457    const diff = Date.now() - ts;458    const mins = Math.floor(diff / 60_000);459    if (mins < 2) return "just now";460    if (mins < 60) return `${mins} minutes ago`;461    const hours = Math.floor(mins / 60);462    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;463    const days = Math.floor(hours / 24);464    return `${days} day${days === 1 ? "" : "s"} ago`;465  }466467  function selectSnapshot(index: number) {468    setSelected(index);469    setConfirming(false);470    setPassword("");471    setError("");472  }473474  async function handleRestore() {475    const idx = selected();476    if (idx === null || !password()) return;477    setRestoring(true);478    setError("");479    const res = await sendMessage({480      type: "RESTORE_VAULT_SNAPSHOT",481      payload: { index: idx, password: password() },482    });483    if ((res as { ok: boolean }).ok) {484      await props.onRestored();485    } else {486      setError("Incorrect password, or this snapshot cannot be decrypted.");487      setRestoring(false);488    }489  }490491  const selectedSnap = () => {492    const idx = selected();493    return idx !== null ? (snapshots()[idx] ?? null) : null;494  };495496  return (497    <section498      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"499      data-motion-phase="enter"500    >501      <header class="peach-screen-header lock-screen-header">502        <PeachWordmark class="peach-wordmark-wrap" />503        <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>504          ‹ back505        </button>506      </header>507508      <div class="peach-lock-hero">509        <span class="peach-lock-eyebrow">vault history</span>510        <h1 class="peach-onboarding-headline">511          revert to512          <br />513          a snapshot.514        </h1>515      </div>516517      <section class="peach-lock-panel peach-snapshot-panel">518        <Show when={loading()}>519          <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>520        </Show>521522        <Show when={!loading() && snapshots().length === 0}>523          <p class="peach-hint" style="line-height:1.6;">524            No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.525          </p>526        </Show>527528        <Show when={!loading() && snapshots().length > 0}>529          <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>530          <div class="peach-snapshot-list">531            <For each={snapshots()}>532              {(snap) => (533                <button534                  class="peach-snapshot-row"535                  classList={{ "is-selected": selected() === snap.index }}536                  onClick={() => selectSnapshot(snap.index)}537                  disabled={restoring()}538                >539                  <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>540                  <span class="peach-snapshot-count">541                    {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}542                  </span>543                </button>544              )}545            </For>546          </div>547548          <Show when={selected() !== null}>549            <div class="peach-snapshot-confirm">550              <label class="peach-label" for="snapshot-pw">master password</label>551              <input552                id="snapshot-pw"553                class="peach-underline-input peach-unlock-input"554                type="password"555                placeholder="••••••••••••"556                autocomplete="current-password"557                value={password()}558                disabled={restoring()}559                onInput={(e) => setPassword((e.target as HTMLInputElement).value)}560                onKeyDown={(e) => {561                  if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();562                }}563              />564565              <Show when={error()}>566                <p class="peach-hint peach-hint-error">{error()}</p>567              </Show>568569              <Show570                when={confirming()}571                fallback={572                  <button573                    class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"574                    onClick={() => setConfirming(true)}575                    disabled={!password().trim() || restoring()}576                  >577                    restore this version →578                  </button>579                }580              >581                <div class="peach-snapshot-warning">582                  <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">583                    <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />584                    <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />585                  </svg>586                  This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.587                </div>588                <div class="peach-snapshot-confirm-row">589                  <button590                    class="peach-btn peach-btn--accent peach-btn--sm"591                    style="flex:1"592                    onClick={() => void handleRestore()}593                    disabled={restoring() || !password().trim()}594                  >595                    {restoring() ? "restoring…" : "yes, restore"}596                  </button>597                  <button598                    class="peach-btn peach-btn--ghost peach-btn--sm"599                    onClick={() => setConfirming(false)}600                    disabled={restoring()}601                  >602                    cancel603                  </button>604                </div>605              </Show>606            </div>607          </Show>608        </Show>609      </section>610    </section>611  );612}613614// ── Helpers ────────────────────────────────────────────────────────────────615616function getDisplayError(error: string | undefined, fallback: string): string {617  if (!error) return fallback;618  if (619    error === "SERVICE_WORKER_UNAVAILABLE" ||620    error.includes("Receiving end does not exist") ||621    error.includes("message port closed")622  ) {623    return "background service unavailable";624  }625  return error;626}