Lock.tsx

23.3 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 lock-screen--onboarding"242          data-motion-phase="enter"243        >244          <header class="peach-screen-header lock-screen-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-lock-hero">254            <div class="peach-lock-hero-art" aria-hidden="true">255              <PeachMark class="peach-lock-hero-mark" />256            </div>257            <span class="peach-lock-eyebrow">new vault</span>258            <h1 class="peach-onboarding-headline">259              keep your260              <br />261              secrets.262            </h1>263          </div>264265          <section class="peach-lock-panel peach-lock-panel--primary">266            <div class="peach-lock-label-row">267              <label class="peach-label" for="master-password">268                master password269              </label>270              <button271                class="peach-lock-reveal-btn"272                type="button"273                onClick={() => setShowPassword((value) => !value)}274                aria-label={showPassword() ? "Hide password" : "Show password"}275              >276                <Show277                  when={showPassword()}278                  fallback={279                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">280                      <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" />281                      <circle cx="7" cy="5" r="1.75" />282                    </svg>283                  }284                >285                  <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">286                    <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />287                    <path d="M3.5 5.5L2.5 7.5" />288                    <path d="M7 6.5V8.5" />289                    <path d="M10.5 5.5L11.5 7.5" />290                  </svg>291                </Show>292                {showPassword() ? "hide" : "show"}293              </button>294            </div>295            <input296              id="master-password"297              class="peach-underline-input peach-unlock-input lock-password-input"298              type={showPassword() ? "text" : "password"}299              placeholder="at least 12 characters"300              autocomplete="new-password"301              value={password()}302              disabled={!props.ready}303              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}304              onKeyDown={(event) => {305                if (event.key !== "Enter" || !props.ready) return;306                void handleCreate();307              }}308            />309310            <Show when={password().length > 0 && passwordStrength() !== null}>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>318319            <label class="peach-label" for="confirm-password">320              confirm password321            </label>322            <input323              id="confirm-password"324              class="peach-underline-input peach-unlock-input"325              type={showPassword() ? "text" : "password"}326              placeholder="••••••••••••"327              autocomplete="new-password"328              value={confirmPassword()}329              disabled={!props.ready}330              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}331              onKeyDown={(event) => {332                if (event.key === "Enter" && props.ready) void handleCreate();333              }}334            />335336            <Show when={error()}>337              <p class="peach-hint peach-hint-error">{error()}</p>338            </Show>339340            <button341              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"342              onClick={() => void handleCreate()}343              disabled={!props.ready || loading() || !canCreate()}344            >345              {loading() ? "creating..." : "create vault"}346            </button>347348            <p class="peach-hint peach-hint-error peach-lock-warning">349              If you lose this password, Peach cannot recover your vault. There is no reset.350            </p>351352            <div class="peach-lock-panel-footer">353              <p class="peach-hint">Already have a backup?</p>354              <button355                class="peach-lock-restore-link"356                onClick={() => openRestore("restore")}357                disabled={!props.ready}358              >359                › restore from backup360              </button>361            </div>362          </section>363        </section>364      </Match>365    </Switch>366  );367}368369// ── Restore method picker ──────────────────────────────────────────────────370371function RestorePickerView(props: {372  onBack: () => void;373  onPickPeachScript: () => void;374  onPickFile: () => void;375  onPickSnapshots: () => void;376}) {377  return (378    <section379      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"380      data-motion-phase="enter"381    >382      <header class="peach-screen-header lock-screen-header">383        <PeachWordmark class="peach-wordmark-wrap" />384        <button class="peach-lock-restore-link" onClick={props.onBack}>385          ‹ back386        </button>387      </header>388389      <div class="peach-lock-hero">390        <span class="peach-lock-eyebrow">restore</span>391        <h1 class="peach-onboarding-headline">392          bring back393          <br />394          your vault.395        </h1>396      </div>397398      <section class="peach-lock-panel peach-lock-panel--restore">399        <p class="peach-hint" style="margin-bottom:2px;">How did you back up your data?</p>400401        <button class="peach-lock-option" onClick={props.onPickPeachScript}>402          <span class="peach-lock-option-title">peachscript</span>403          <span class="peach-lock-option-copy">404            A string of Peach symbols you exported from another Peach device. You'll need the backup password you set at the time.405          </span>406        </button>407408        <button class="peach-lock-option" onClick={props.onPickFile}>409          <span class="peach-lock-option-title">backup file</span>410          <span class="peach-lock-option-copy">411            A Peach JSON export, or a file from Bitwarden, Proton Pass, 1Password, or a browser (Chrome, Safari, Edge CSV).412          </span>413        </button>414415        <button class="peach-lock-option peach-lock-option--history" onClick={props.onPickSnapshots}>416          <span class="peach-lock-option-title">vault history</span>417          <span class="peach-lock-option-copy">418            Peach saves encrypted snapshots locally after each change. Revert to an earlier version if something went wrong.419          </span>420        </button>421      </section>422    </section>423  );424}425426// ── Vault snapshot restore ─────────────────────────────────────────────────427428interface SnapshotSummary {429  index: number;430  timestamp: number;431  entryCount: number;432}433434function SnapshotRestoreView(props: {435  onBack: () => void;436  onRestored: () => void | Promise<void>;437}) {438  const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);439  const [loading, setLoading] = createSignal(true);440  const [selected, setSelected] = createSignal<number | null>(null);441  const [password, setPassword] = createSignal("");442  const [confirming, setConfirming] = createSignal(false);443  const [restoring, setRestoring] = createSignal(false);444  const [error, setError] = createSignal("");445446  onMount(async () => {447    const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });448    setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);449    setLoading(false);450  });451452  function formatAge(ts: number): string {453    const diff = Date.now() - ts;454    const mins = Math.floor(diff / 60_000);455    if (mins < 2) return "just now";456    if (mins < 60) return `${mins} minutes ago`;457    const hours = Math.floor(mins / 60);458    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;459    const days = Math.floor(hours / 24);460    return `${days} day${days === 1 ? "" : "s"} ago`;461  }462463  function selectSnapshot(index: number) {464    setSelected(index);465    setConfirming(false);466    setPassword("");467    setError("");468  }469470  async function handleRestore() {471    const idx = selected();472    if (idx === null || !password()) return;473    setRestoring(true);474    setError("");475    const res = await sendMessage({476      type: "RESTORE_VAULT_SNAPSHOT",477      payload: { index: idx, password: password() },478    });479    if ((res as { ok: boolean }).ok) {480      await props.onRestored();481    } else {482      setError("Incorrect password, or this snapshot cannot be decrypted.");483      setRestoring(false);484    }485  }486487  const selectedSnap = () => {488    const idx = selected();489    return idx !== null ? (snapshots()[idx] ?? null) : null;490  };491492  return (493    <section494      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"495      data-motion-phase="enter"496    >497      <header class="peach-screen-header lock-screen-header">498        <PeachWordmark class="peach-wordmark-wrap" />499        <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>500          ‹ back501        </button>502      </header>503504      <div class="peach-lock-hero">505        <span class="peach-lock-eyebrow">vault history</span>506        <h1 class="peach-onboarding-headline">507          revert to508          <br />509          a snapshot.510        </h1>511      </div>512513      <section class="peach-lock-panel peach-snapshot-panel">514        <Show when={loading()}>515          <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>516        </Show>517518        <Show when={!loading() && snapshots().length === 0}>519          <p class="peach-hint" style="line-height:1.6;">520            No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.521          </p>522        </Show>523524        <Show when={!loading() && snapshots().length > 0}>525          <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>526          <div class="peach-snapshot-list">527            <For each={snapshots()}>528              {(snap) => (529                <button530                  class="peach-snapshot-row"531                  classList={{ "is-selected": selected() === snap.index }}532                  onClick={() => selectSnapshot(snap.index)}533                  disabled={restoring()}534                >535                  <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>536                  <span class="peach-snapshot-count">537                    {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}538                  </span>539                </button>540              )}541            </For>542          </div>543544          <Show when={selected() !== null}>545            <div class="peach-snapshot-confirm">546              <label class="peach-label" for="snapshot-pw">master password</label>547              <input548                id="snapshot-pw"549                class="peach-underline-input peach-unlock-input"550                type="password"551                placeholder="••••••••••••"552                autocomplete="current-password"553                value={password()}554                disabled={restoring()}555                onInput={(e) => setPassword((e.target as HTMLInputElement).value)}556                onKeyDown={(e) => {557                  if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();558                }}559              />560561              <Show when={error()}>562                <p class="peach-hint peach-hint-error">{error()}</p>563              </Show>564565              <Show566                when={confirming()}567                fallback={568                  <button569                    class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"570                    onClick={() => setConfirming(true)}571                    disabled={!password().trim() || restoring()}572                  >573                    restore this version →574                  </button>575                }576              >577                <div class="peach-snapshot-warning">578                  <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">579                    <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />580                    <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />581                  </svg>582                  This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.583                </div>584                <div class="peach-snapshot-confirm-row">585                  <button586                    class="peach-btn peach-btn--accent peach-btn--sm"587                    style="flex:1"588                    onClick={() => void handleRestore()}589                    disabled={restoring() || !password().trim()}590                  >591                    {restoring() ? "restoring…" : "yes, restore"}592                  </button>593                  <button594                    class="peach-btn peach-btn--ghost peach-btn--sm"595                    onClick={() => setConfirming(false)}596                    disabled={restoring()}597                  >598                    cancel599                  </button>600                </div>601              </Show>602            </div>603          </Show>604        </Show>605      </section>606    </section>607  );608}609610// ── Helpers ────────────────────────────────────────────────────────────────611612function getDisplayError(error: string | undefined, fallback: string): string {613  if (!error) return fallback;614  if (615    error === "SERVICE_WORKER_UNAVAILABLE" ||616    error.includes("Receiving end does not exist") ||617    error.includes("message port closed")618  ) {619    return "background service unavailable";620  }621  return error;622}