Lock.tsx

24.5 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  /** Optional escape hatch: rewind from the create-vault screen back to23   *  Onboarding. Only meaningful when the user reached create-vault from24   *  Onboarding in the first place. */25  onBackToOnboarding?: () => void;26}2728// "restore" = picker, "snapshots" = vault history, "peachscript" / "file" = detail flows29type RestoreSurface = "restore" | "snapshots" | "peachscript" | "file" | null;3031export function LockView(props: LockProps) {32  const [password, setPassword] = createSignal("");33  const [confirmPassword, setConfirmPassword] = createSignal("");34  const [error, setError] = createSignal("");35  const [loading, setLoading] = createSignal(false);36  const [restoreSurface, setRestoreSurface] = createSignal<RestoreSurface>(null);37  const [showPassword, setShowPassword] = createSignal(false);3839  const MIN_LENGTH = 12;40  const MIN_ZXCVBN_SCORE = 2;4142  const [strengthScore, setStrengthScore] = createSignal<number | null>(null);4344  const canCreate = createMemo(45    () =>46      password().length >= MIN_LENGTH &&47      confirmPassword().length >= MIN_LENGTH &&48      password() === confirmPassword() &&49      strengthScore() !== null &&50      strengthScore()! >= MIN_ZXCVBN_SCORE51  );5253  const passwordStrength = createMemo(() => {54    const score = strengthScore();55    if (score === null) return null;56    if (score <= 1) return "weak";57    if (score === 2) return "fair";58    if (score === 3) return "good";59    return "strong";60  });6162  // Update strength asynchronously when password changes63  createMemo(() => {64    const value = password();65    if (!value) { setStrengthScore(null); return; }66    getZxcvbn().then((zxcvbn) => {67      if (password() === value) setStrengthScore(zxcvbn(value).score);68    }).catch(() => {69      // zxcvbn unavailable — fall back to length-only heuristic70      if (password() === value) setStrengthScore(value.length >= 16 ? 3 : value.length >= 12 ? 2 : 1);71    });72  });7374  async function handleUnlock() {75    if (!password().trim()) return;76    setLoading(true);77    setError("");78    const response = await sendMessage({79      type: "UNLOCK",80      payload: { password: password() },81    });82    const result = response as { ok: boolean; error?: string };83    if (result.ok) {84      await props.onUnlock();85      setLoading(false);86      return;87    }88    setError(getDisplayError(result.error, "incorrect password"));89    setPassword("");90    const passwordField = document.querySelector(".lock-password-input") as HTMLInputElement | undefined;91    passwordField?.classList.add("shake");92    setTimeout(() => passwordField?.classList.remove("shake"), 300);93    setLoading(false);94  }9596  async function handleCreate() {97    if (!password().trim()) return;98    if (password().length < MIN_LENGTH) {99      setError(`password must be at least ${MIN_LENGTH} characters`);100      return;101    }102    if (password() !== confirmPassword()) {103      setError("passwords do not match");104      return;105    }106    const score = strengthScore();107    if (score === null || score < MIN_ZXCVBN_SCORE) {108      setError("password is too weak — try a longer or more varied passphrase");109      return;110    }111    setLoading(true);112    setError("");113    const response = await sendMessage({114      type: "CREATE_VAULT",115      payload: { password: password() },116    });117    const result = response as { ok: boolean; error?: string };118    if (result.ok) {119      await props.onUnlock();120      setLoading(false);121      return;122    }123    setError(getDisplayError(result.error, "failed to create vault"));124    setLoading(false);125  }126127  function openRestore(surface: Exclude<RestoreSurface, null>) {128    if (!props.ready || loading()) return;129    setPassword("");130    setConfirmPassword("");131    setError("");132    setRestoreSurface(surface);133  }134135  // ── All surfaces routed reactively via Switch/Match ───────────────────136  return (137    <Switch>138      {/* ── Restore picker ────────────────────────────────────────── */}139      <Match when={restoreSurface() === "restore"}>140        <RestorePickerView141          onBack={() => setRestoreSurface(null)}142          onPickPeachScript={() => setRestoreSurface("peachscript")}143          onPickFile={() => setRestoreSurface("file")}144          onPickSnapshots={() => setRestoreSurface("snapshots")}145        />146      </Match>147148      {/* ── Vault snapshot history ────────────────────────────────── */}149      <Match when={restoreSurface() === "snapshots"}>150        <SnapshotRestoreView151          onBack={() => setRestoreSurface("restore")}152          onRestored={props.onUnlock}153        />154      </Match>155156      {/* ── PeachScript restore ───────────────────────────────────── */}157      <Match when={restoreSurface() === "peachscript"}>158        <ImportView159          mode="onboarding"160          replacesExistingVault={props.hasVault}161          initialView="peachscript"162          onBack={() => setRestoreSurface("restore")}163          onImport={async () => {164            setRestoreSurface(null);165            await props.onUnlock();166          }}167        />168      </Match>169170      {/* ── File restore ──────────────────────────────────────────── */}171      <Match when={restoreSurface() === "file"}>172        <ImportView173          mode="onboarding"174          replacesExistingVault={props.hasVault}175          initialView="file"176          onBack={() => setRestoreSurface("restore")}177          onImport={async () => {178            setRestoreSurface(null);179            await props.onUnlock();180          }}181        />182      </Match>183184      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}185      <Match when={props.hasVault}>186        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">187          <header class="peach-screen-header lock-screen-header">188            <div class="peach-wordmark-stack">189              <PeachWordmark class="peach-wordmark-wrap" />190              <Show when={props.isPro}>191                <span class="peach-pro-mini-badge">pro</span>192              </Show>193            </div>194          </header>195196          <div class="peach-lock-hero peach-lock-hero--unlock">197            <div class="peach-lock-hero-art" aria-hidden="true">198              <PeachMark class="peach-lock-hero-mark" />199            </div>200            <span class="peach-lock-eyebrow">vault locked</span>201            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">202              unlock203              <br />204              your vault.205            </h1>206          </div>207208          <LockRuneCanvas />209210          <section class="peach-lock-panel peach-lock-panel--unlock peach-glass">211            <label class="peach-label" for="unlock-password">212              master password213            </label>214            <input215              id="unlock-password"216              class="peach-underline-input peach-unlock-input lock-password-input"217              type="password"218              placeholder="••••••••••••"219              autocomplete="current-password"220              value={password()}221              disabled={!props.ready}222              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}223              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}224              onKeyDown={(event) => {225                if (event.key !== "Enter" || !props.ready) return;226                void handleUnlock();227              }}228            />229230            <Show when={error()}>231              <p class="peach-hint peach-hint-error">{error()}</p>232            </Show>233234            <button235              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"236              onClick={() => void handleUnlock()}237              disabled={!props.ready || loading()}238            >239              {loading() ? "unlocking..." : "unlock"}240            </button>241          </section>242        </section>243      </Match>244245      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}246      <Match when={true}>247        <section248          class="peach-screen peach-theme lock-screen lock-screen--vault-stage peach-newvault"249          data-motion-phase="enter"250        >251          <header class="peach-screen-header peach-newvault-header">252            <Show when={props.onBackToOnboarding}>253              <button254                class="peach-newvault-back"255                type="button"256                onClick={() => props.onBackToOnboarding?.()}257                aria-label="Back to onboarding"258              >259                <svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round">260                  <path d="M7 2L3 5.5L7 9" />261                </svg>262                back263              </button>264            </Show>265            <div class="peach-wordmark-stack">266              <PeachWordmark class="peach-wordmark-wrap" />267              <Show when={props.isPro}>268                <span class="peach-pro-mini-badge">pro</span>269              </Show>270            </div>271          </header>272273          <div class="peach-newvault-mark" aria-hidden="true">274            <PeachMark class="peach-newvault-mark-art" />275          </div>276277          <div class="peach-newvault-card">278            <h1 class="peach-newvault-headline">{"keep your\nsecrets."}</h1>279            <hr class="peach-newvault-divider" />280281            <div class="peach-newvault-field">282              <div class="peach-newvault-label-row">283                <label class="peach-label" for="master-password">284                  master password285                </label>286                <button287                  class="peach-lock-reveal-btn"288                  type="button"289                  onClick={() => setShowPassword((value) => !value)}290                  aria-label={showPassword() ? "Hide password" : "Show password"}291                >292                  <Show293                    when={showPassword()}294                    fallback={295                      <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">296                        <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" />297                        <circle cx="7" cy="5" r="1.75" />298                      </svg>299                    }300                  >301                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">302                      <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />303                      <path d="M3.5 5.5L2.5 7.5" />304                      <path d="M7 6.5V8.5" />305                      <path d="M10.5 5.5L11.5 7.5" />306                    </svg>307                  </Show>308                  {showPassword() ? "hide" : "show"}309                </button>310              </div>311              <input312                id="master-password"313                class="peach-underline-input peach-newvault-input lock-password-input"314                type={showPassword() ? "text" : "password"}315                placeholder="••••••••••••"316                autocomplete="new-password"317                value={password()}318                disabled={!props.ready}319                onInput={(event) => setPassword((event.target as HTMLInputElement).value)}320                onKeyDown={(event) => {321                  if (event.key !== "Enter" || !props.ready) return;322                  void handleCreate();323                }}324              />325              <Show326                when={password().length > 0 && passwordStrength() !== null}327                fallback={328                  <p class="peach-newvault-hint">at least 12 characters</p>329                }330              >331                <div class="peach-password-strength">332                  <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>333                    <div class="peach-strength-fill"></div>334                  </div>335                  <span class="peach-strength-label">{passwordStrength()}</span>336                </div>337              </Show>338            </div>339340            <div class="peach-newvault-field">341              <label class="peach-label" for="confirm-password">342                confirm password343              </label>344              <input345                id="confirm-password"346                class="peach-underline-input peach-newvault-input"347                type={showPassword() ? "text" : "password"}348                placeholder="••••••••••••"349                autocomplete="new-password"350                value={confirmPassword()}351                disabled={!props.ready}352                onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}353                onKeyDown={(event) => {354                  if (event.key === "Enter" && props.ready) void handleCreate();355                }}356              />357            </div>358359            <Show when={error()}>360              <p class="peach-hint peach-hint-error">{error()}</p>361            </Show>362363            <button364              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit peach-newvault-submit"365              onClick={() => void handleCreate()}366              disabled={!props.ready || loading() || !canCreate()}367            >368              {loading() ? "creating..." : "create vault"}369            </button>370371            <p class="peach-newvault-warning">372              <span class="peach-newvault-warning-icon" aria-hidden="true">⚠</span>373              <span>If you forget it, your vault cannot be opened. There is no reset.</span>374            </p>375          </div>376377          <div class="peach-newvault-footer">378            <p class="peach-hint">Already have a backup?</p>379            <button380              class="peach-lock-restore-link"381              onClick={() => openRestore("restore")}382              disabled={!props.ready}383            >384              › restore from backup385            </button>386          </div>387        </section>388      </Match>389    </Switch>390  );391}392393// ── Restore method picker ──────────────────────────────────────────────────394395function RestorePickerView(props: {396  onBack: () => void;397  onPickPeachScript: () => void;398  onPickFile: () => void;399  onPickSnapshots: () => void;400}) {401  return (402    <section403      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"404      data-motion-phase="enter"405    >406      <header class="peach-screen-header lock-screen-header">407        <PeachWordmark class="peach-wordmark-wrap" />408        <button class="peach-lock-restore-link" onClick={props.onBack}>409          ‹ back410        </button>411      </header>412413      <div class="peach-lock-hero">414        <span class="peach-lock-eyebrow">restore</span>415        <h1 class="peach-onboarding-headline">416          bring back417          <br />418          your vault.419        </h1>420      </div>421422      <section class="peach-lock-panel peach-lock-panel--restore">423        <p class="peach-hint" style="margin-bottom:2px;">How did you back up your data?</p>424425        <button class="peach-lock-option" onClick={props.onPickPeachScript}>426          <span class="peach-lock-option-title">peachscript</span>427          <span class="peach-lock-option-copy">428            A string of Peach symbols you exported from another Peach device. You'll need the backup password you set at the time.429          </span>430        </button>431432        <button class="peach-lock-option" onClick={props.onPickFile}>433          <span class="peach-lock-option-title">backup file</span>434          <span class="peach-lock-option-copy">435            A Peach JSON export, or a file from Bitwarden, Proton Pass, 1Password, or a browser (Chrome, Safari, Edge CSV).436          </span>437        </button>438439        <button class="peach-lock-option peach-lock-option--history" onClick={props.onPickSnapshots}>440          <span class="peach-lock-option-title">vault history</span>441          <span class="peach-lock-option-copy">442            Peach saves encrypted snapshots locally after each change. Revert to an earlier version if something went wrong.443          </span>444        </button>445      </section>446    </section>447  );448}449450// ── Vault snapshot restore ─────────────────────────────────────────────────451452interface SnapshotSummary {453  index: number;454  timestamp: number;455  entryCount: number;456}457458function SnapshotRestoreView(props: {459  onBack: () => void;460  onRestored: () => void | Promise<void>;461}) {462  const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);463  const [loading, setLoading] = createSignal(true);464  const [selected, setSelected] = createSignal<number | null>(null);465  const [password, setPassword] = createSignal("");466  const [confirming, setConfirming] = createSignal(false);467  const [restoring, setRestoring] = createSignal(false);468  const [error, setError] = createSignal("");469470  onMount(async () => {471    const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });472    setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);473    setLoading(false);474  });475476  function formatAge(ts: number): string {477    const diff = Date.now() - ts;478    const mins = Math.floor(diff / 60_000);479    if (mins < 2) return "just now";480    if (mins < 60) return `${mins} minutes ago`;481    const hours = Math.floor(mins / 60);482    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;483    const days = Math.floor(hours / 24);484    return `${days} day${days === 1 ? "" : "s"} ago`;485  }486487  function selectSnapshot(index: number) {488    setSelected(index);489    setConfirming(false);490    setPassword("");491    setError("");492  }493494  async function handleRestore() {495    const idx = selected();496    if (idx === null || !password()) return;497    setRestoring(true);498    setError("");499    const res = await sendMessage({500      type: "RESTORE_VAULT_SNAPSHOT",501      payload: { index: idx, password: password() },502    });503    if ((res as { ok: boolean }).ok) {504      await props.onRestored();505    } else {506      setError("Incorrect password, or this snapshot cannot be decrypted.");507      setRestoring(false);508    }509  }510511  const selectedSnap = () => {512    const idx = selected();513    return idx !== null ? (snapshots()[idx] ?? null) : null;514  };515516  return (517    <section518      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"519      data-motion-phase="enter"520    >521      <header class="peach-screen-header lock-screen-header">522        <PeachWordmark class="peach-wordmark-wrap" />523        <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>524          ‹ back525        </button>526      </header>527528      <div class="peach-lock-hero">529        <span class="peach-lock-eyebrow">vault history</span>530        <h1 class="peach-onboarding-headline">531          revert to532          <br />533          a snapshot.534        </h1>535      </div>536537      <section class="peach-lock-panel peach-snapshot-panel">538        <Show when={loading()}>539          <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>540        </Show>541542        <Show when={!loading() && snapshots().length === 0}>543          <p class="peach-hint" style="line-height:1.6;">544            No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.545          </p>546        </Show>547548        <Show when={!loading() && snapshots().length > 0}>549          <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>550          <div class="peach-snapshot-list">551            <For each={snapshots()}>552              {(snap) => (553                <button554                  class="peach-snapshot-row"555                  classList={{ "is-selected": selected() === snap.index }}556                  onClick={() => selectSnapshot(snap.index)}557                  disabled={restoring()}558                >559                  <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>560                  <span class="peach-snapshot-count">561                    {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}562                  </span>563                </button>564              )}565            </For>566          </div>567568          <Show when={selected() !== null}>569            <div class="peach-snapshot-confirm">570              <label class="peach-label" for="snapshot-pw">master password</label>571              <input572                id="snapshot-pw"573                class="peach-underline-input peach-unlock-input"574                type="password"575                placeholder="••••••••••••"576                autocomplete="current-password"577                value={password()}578                disabled={restoring()}579                onInput={(e) => setPassword((e.target as HTMLInputElement).value)}580                onKeyDown={(e) => {581                  if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();582                }}583              />584585              <Show when={error()}>586                <p class="peach-hint peach-hint-error">{error()}</p>587              </Show>588589              <Show590                when={confirming()}591                fallback={592                  <button593                    class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"594                    onClick={() => setConfirming(true)}595                    disabled={!password().trim() || restoring()}596                  >597                    restore this version →598                  </button>599                }600              >601                <div class="peach-snapshot-warning">602                  <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">603                    <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />604                    <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />605                  </svg>606                  This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.607                </div>608                <div class="peach-snapshot-confirm-row">609                  <button610                    class="peach-btn peach-btn--accent peach-btn--sm"611                    style="flex:1"612                    onClick={() => void handleRestore()}613                    disabled={restoring() || !password().trim()}614                  >615                    {restoring() ? "restoring…" : "yes, restore"}616                  </button>617                  <button618                    class="peach-btn peach-btn--ghost peach-btn--sm"619                    onClick={() => setConfirming(false)}620                    disabled={restoring()}621                  >622                    cancel623                  </button>624                </div>625              </Show>626            </div>627          </Show>628        </Show>629      </section>630    </section>631  );632}633634// ── Helpers ────────────────────────────────────────────────────────────────635636function getDisplayError(error: string | undefined, fallback: string): string {637  if (!error) return fallback;638  if (639    error === "SERVICE_WORKER_UNAVAILABLE" ||640    error.includes("Receiving end does not exist") ||641    error.includes("message port closed")642  ) {643    return "background service unavailable";644  }645  return error;646}