Lock.tsx

22.8 KB · raw

1import { For, Match, Show, Switch, createMemo, createSignal, onMount } from "solid-js";23import { sendMessage } from "../App";4import { PeachMark, PeachWordmark } from "../components/PeachMark";5import { CJKImportView } from "./CJKImport";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  onUnlock: () => void | Promise<void>;21}2223// "restore" = picker, "snapshots" = vault history, "cjk" / "file" = detail flows24type RestoreSurface = "restore" | "snapshots" | "cjk" | "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          onPickCjk={() => setRestoreSurface("cjk")}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      {/* ── CJK restore ───────────────────────────────────────────── */}152      <Match when={restoreSurface() === "cjk"}>153        <CJKImportView154          mode="onboarding"155          onBack={() => setRestoreSurface("restore")}156          onDone={async () => {157            setRestoreSurface(null);158            await props.onUnlock();159          }}160        />161      </Match>162163      {/* ── File restore ──────────────────────────────────────────── */}164      <Match when={restoreSurface() === "file"}>165        <ImportView166          mode="onboarding"167          replacesExistingVault={props.hasVault}168          onBack={() => setRestoreSurface("restore")}169          onImport={async () => {170            setRestoreSurface(null);171            await props.onUnlock();172          }}173        />174      </Match>175176      {/* ── Unlock (hasVault = true) ───────────────────────────────── */}177      <Match when={props.hasVault}>178        <section class="peach-screen peach-theme lock-screen lock-screen--vault-stage" data-motion-phase="enter">179          <header class="peach-screen-header lock-screen-header">180            <PeachWordmark class="peach-wordmark-wrap" />181          </header>182183          <div class="peach-lock-hero peach-lock-hero--unlock">184            <div class="peach-lock-hero-art" aria-hidden="true">185              <PeachMark class="peach-lock-hero-mark" />186            </div>187            <span class="peach-lock-eyebrow">vault locked</span>188            <h1 class="peach-onboarding-headline peach-lock-headline--unlock">189              unlock190              <br />191              your vault.192            </h1>193          </div>194195          <section class="peach-lock-panel peach-lock-panel--unlock">196            <label class="peach-label" for="unlock-password">197              master password198            </label>199            <input200              id="unlock-password"201              class="peach-underline-input peach-unlock-input lock-password-input"202              type="password"203              placeholder="••••••••••••"204              autocomplete="current-password"205              value={password()}206              disabled={!props.ready}207              ref={(el) => { if (props.ready) setTimeout(() => el.focus(), 80); }}208              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}209              onKeyDown={(event) => {210                if (event.key !== "Enter" || !props.ready) return;211                void handleUnlock();212              }}213            />214215            <Show when={error()}>216              <p class="peach-hint peach-hint-error">{error()}</p>217            </Show>218219            <button220              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"221              onClick={() => void handleUnlock()}222              disabled={!props.ready || loading()}223            >224              {loading() ? "unlocking..." : "unlock"}225            </button>226          </section>227        </section>228      </Match>229230      {/* ── Onboarding (hasVault = false) ─────────────────────────── */}231      <Match when={true}>232        <section233          class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"234          data-motion-phase="enter"235        >236          <header class="peach-screen-header lock-screen-header">237            <PeachWordmark class="peach-wordmark-wrap" />238          </header>239240          <div class="peach-lock-hero">241            <div class="peach-lock-hero-art" aria-hidden="true">242              <PeachMark class="peach-lock-hero-mark" />243            </div>244            <span class="peach-lock-eyebrow">new vault</span>245            <h1 class="peach-onboarding-headline">246              keep your247              <br />248              secrets.249            </h1>250          </div>251252          <section class="peach-lock-panel peach-lock-panel--primary">253            <div class="peach-lock-label-row">254              <label class="peach-label" for="master-password">255                master password256              </label>257              <button258                class="peach-lock-reveal-btn"259                type="button"260                onClick={() => setShowPassword((value) => !value)}261                aria-label={showPassword() ? "Hide password" : "Show password"}262              >263                <Show264                  when={showPassword()}265                  fallback={266                    <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">267                      <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" />268                      <circle cx="7" cy="5" r="1.75" />269                    </svg>270                  }271                >272                  <svg width="15" height="10" viewBox="0 0 14 10" fill="none" stroke="currentColor" stroke-width="0.9" stroke-linecap="round">273                    <path d="M1 2C3 4.5 5 6 7 6C9 6 11 4.5 13 2" />274                    <path d="M3.5 5.5L2.5 7.5" />275                    <path d="M7 6.5V8.5" />276                    <path d="M10.5 5.5L11.5 7.5" />277                  </svg>278                </Show>279                {showPassword() ? "hide" : "show"}280              </button>281            </div>282            <input283              id="master-password"284              class="peach-underline-input peach-unlock-input lock-password-input"285              type={showPassword() ? "text" : "password"}286              placeholder="at least 12 characters"287              autocomplete="new-password"288              value={password()}289              disabled={!props.ready}290              onInput={(event) => setPassword((event.target as HTMLInputElement).value)}291              onKeyDown={(event) => {292                if (event.key !== "Enter" || !props.ready) return;293                void handleCreate();294              }}295            />296297            <Show when={password().length > 0 && passwordStrength() !== null}>298              <div class="peach-password-strength">299                <div class={`peach-strength-bar peach-strength-bar--${passwordStrength()}`}>300                  <div class="peach-strength-fill"></div>301                </div>302                <span class="peach-strength-label">{passwordStrength()}</span>303              </div>304            </Show>305306            <label class="peach-label" for="confirm-password">307              confirm password308            </label>309            <input310              id="confirm-password"311              class="peach-underline-input peach-unlock-input"312              type={showPassword() ? "text" : "password"}313              placeholder="••••••••••••"314              autocomplete="new-password"315              value={confirmPassword()}316              disabled={!props.ready}317              onInput={(event) => setConfirmPassword((event.target as HTMLInputElement).value)}318              onKeyDown={(event) => {319                if (event.key === "Enter" && props.ready) void handleCreate();320              }}321            />322323            <Show when={error()}>324              <p class="peach-hint peach-hint-error">{error()}</p>325            </Show>326327            <button328              class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"329              onClick={() => void handleCreate()}330              disabled={!props.ready || loading() || !canCreate()}331            >332              {loading() ? "creating..." : "create vault"}333            </button>334335            <p class="peach-hint peach-hint-error peach-lock-warning">336              If you lose this password, Peach cannot recover your vault. There is no reset.337            </p>338339            <div class="peach-lock-panel-footer">340              <p class="peach-hint">Already have a backup?</p>341              <button342                class="peach-lock-restore-link"343                onClick={() => openRestore("restore")}344                disabled={!props.ready}345              >346                › restore from backup347              </button>348            </div>349          </section>350        </section>351      </Match>352    </Switch>353  );354}355356// ── Restore method picker ──────────────────────────────────────────────────357358function RestorePickerView(props: {359  onBack: () => void;360  onPickCjk: () => void;361  onPickFile: () => void;362  onPickSnapshots: () => void;363}) {364  return (365    <section366      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"367      data-motion-phase="enter"368    >369      <header class="peach-screen-header lock-screen-header">370        <PeachWordmark class="peach-wordmark-wrap" />371        <button class="peach-lock-restore-link" onClick={props.onBack}>372          ‹ back373        </button>374      </header>375376      <div class="peach-lock-hero">377        <span class="peach-lock-eyebrow">restore</span>378        <h1 class="peach-onboarding-headline">379          bring back380          <br />381          your vault.382        </h1>383      </div>384385      <section class="peach-lock-panel peach-lock-panel--restore">386        <p class="peach-hint" style="margin-bottom:2px;">How did you back up your data?</p>387388        <button class="peach-lock-option" onClick={props.onPickCjk}>389          <span class="peach-lock-option-title">backup phrase</span>390          <span class="peach-lock-option-copy">391            A block of Chinese characters exported directly from Peach. You'll need the export passphrase you set at the time.392          </span>393        </button>394395        <button class="peach-lock-option" onClick={props.onPickFile}>396          <span class="peach-lock-option-title">backup file</span>397          <span class="peach-lock-option-copy">398            A Peach JSON export, or a file from Bitwarden, Proton Pass, 1Password, or a browser (Chrome, Safari, Edge CSV).399          </span>400        </button>401402        <button class="peach-lock-option peach-lock-option--history" onClick={props.onPickSnapshots}>403          <span class="peach-lock-option-title">vault history</span>404          <span class="peach-lock-option-copy">405            Peach saves encrypted snapshots locally after each change. Revert to an earlier version if something went wrong.406          </span>407        </button>408      </section>409    </section>410  );411}412413// ── Vault snapshot restore ─────────────────────────────────────────────────414415interface SnapshotSummary {416  index: number;417  timestamp: number;418  entryCount: number;419}420421function SnapshotRestoreView(props: {422  onBack: () => void;423  onRestored: () => void | Promise<void>;424}) {425  const [snapshots, setSnapshots] = createSignal<SnapshotSummary[]>([]);426  const [loading, setLoading] = createSignal(true);427  const [selected, setSelected] = createSignal<number | null>(null);428  const [password, setPassword] = createSignal("");429  const [confirming, setConfirming] = createSignal(false);430  const [restoring, setRestoring] = createSignal(false);431  const [error, setError] = createSignal("");432433  onMount(async () => {434    const res = await sendMessage({ type: "LIST_VAULT_SNAPSHOTS" });435    setSnapshots((res as { ok: boolean; data?: SnapshotSummary[] }).data ?? []);436    setLoading(false);437  });438439  function formatAge(ts: number): string {440    const diff = Date.now() - ts;441    const mins = Math.floor(diff / 60_000);442    if (mins < 2) return "just now";443    if (mins < 60) return `${mins} minutes ago`;444    const hours = Math.floor(mins / 60);445    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;446    const days = Math.floor(hours / 24);447    return `${days} day${days === 1 ? "" : "s"} ago`;448  }449450  function selectSnapshot(index: number) {451    setSelected(index);452    setConfirming(false);453    setPassword("");454    setError("");455  }456457  async function handleRestore() {458    const idx = selected();459    if (idx === null || !password()) return;460    setRestoring(true);461    setError("");462    const res = await sendMessage({463      type: "RESTORE_VAULT_SNAPSHOT",464      payload: { index: idx, password: password() },465    });466    if ((res as { ok: boolean }).ok) {467      await props.onRestored();468    } else {469      setError("Incorrect password, or this snapshot cannot be decrypted.");470      setRestoring(false);471    }472  }473474  const selectedSnap = () => {475    const idx = selected();476    return idx !== null ? (snapshots()[idx] ?? null) : null;477  };478479  return (480    <section481      class="peach-screen peach-theme lock-screen lock-screen--vault-stage lock-screen--onboarding"482      data-motion-phase="enter"483    >484      <header class="peach-screen-header lock-screen-header">485        <PeachWordmark class="peach-wordmark-wrap" />486        <button class="peach-lock-restore-link" onClick={props.onBack} disabled={restoring()}>487          ‹ back488        </button>489      </header>490491      <div class="peach-lock-hero">492        <span class="peach-lock-eyebrow">vault history</span>493        <h1 class="peach-onboarding-headline">494          revert to495          <br />496          a snapshot.497        </h1>498      </div>499500      <section class="peach-lock-panel peach-snapshot-panel">501        <Show when={loading()}>502          <p class="peach-hint" style="text-align:center;padding:8px 0;">Loading…</p>503        </Show>504505        <Show when={!loading() && snapshots().length === 0}>506          <p class="peach-hint" style="line-height:1.6;">507            No snapshots yet. Peach automatically saves up to 6 encrypted snapshots after each vault change.508          </p>509        </Show>510511        <Show when={!loading() && snapshots().length > 0}>512          <p class="peach-hint" style="margin-bottom:4px;">Select a version to restore:</p>513          <div class="peach-snapshot-list">514            <For each={snapshots()}>515              {(snap) => (516                <button517                  class="peach-snapshot-row"518                  classList={{ "is-selected": selected() === snap.index }}519                  onClick={() => selectSnapshot(snap.index)}520                  disabled={restoring()}521                >522                  <span class="peach-snapshot-time">{formatAge(snap.timestamp)}</span>523                  <span class="peach-snapshot-count">524                    {snap.entryCount} {snap.entryCount === 1 ? "entry" : "entries"}525                  </span>526                </button>527              )}528            </For>529          </div>530531          <Show when={selected() !== null}>532            <div class="peach-snapshot-confirm">533              <label class="peach-label" for="snapshot-pw">master password</label>534              <input535                id="snapshot-pw"536                class="peach-underline-input peach-unlock-input"537                type="password"538                placeholder="••••••••••••"539                autocomplete="current-password"540                value={password()}541                disabled={restoring()}542                onInput={(e) => setPassword((e.target as HTMLInputElement).value)}543                onKeyDown={(e) => {544                  if (e.key === "Enter" && !restoring() && confirming()) void handleRestore();545                }}546              />547548              <Show when={error()}>549                <p class="peach-hint peach-hint-error">{error()}</p>550              </Show>551552              <Show553                when={confirming()}554                fallback={555                  <button556                    class="peach-btn peach-btn--subtle peach-btn--sm peach-snapshot-restore-btn"557                    onClick={() => setConfirming(true)}558                    disabled={!password().trim() || restoring()}559                  >560                    restore this version →561                  </button>562                }563              >564                <div class="peach-snapshot-warning">565                  <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">566                    <path d="M6 1L11 10H1L6 1z" /><line x1="6" y1="5" x2="6" y2="7.5" />567                    <circle cx="6" cy="9" r="0.5" fill="rgba(220,80,60,0.9)" stroke="none" />568                  </svg>569                  This replaces your current vault with the {selectedSnap() ? formatAge(selectedSnap()!.timestamp) : ""} version ({selectedSnap()?.entryCount} entries). It cannot be undone.570                </div>571                <div class="peach-snapshot-confirm-row">572                  <button573                    class="peach-btn peach-btn--accent peach-btn--sm"574                    style="flex:1"575                    onClick={() => void handleRestore()}576                    disabled={restoring() || !password().trim()}577                  >578                    {restoring() ? "restoring…" : "yes, restore"}579                  </button>580                  <button581                    class="peach-btn peach-btn--ghost peach-btn--sm"582                    onClick={() => setConfirming(false)}583                    disabled={restoring()}584                  >585                    cancel586                  </button>587                </div>588              </Show>589            </div>590          </Show>591        </Show>592      </section>593    </section>594  );595}596597// ── Helpers ────────────────────────────────────────────────────────────────598599function getDisplayError(error: string | undefined, fallback: string): string {600  if (!error) return fallback;601  if (602    error === "SERVICE_WORKER_UNAVAILABLE" ||603    error.includes("Receiving end does not exist") ||604    error.includes("message port closed")605  ) {606    return "background service unavailable";607  }608  return error;609}