Health.tsx

21.2 KB · raw

1import { createMemo, createSignal, For, onMount, Show } from "solid-js";2import { zxcvbn, zxcvbnOptions } from "@zxcvbn-ts/core";3import * as zxcvbnCommon from "@zxcvbn-ts/language-common";4import * as zxcvbnEn from "@zxcvbn-ts/language-en";5import type { AdvancedHealthReport, VaultEntry } from "../../shared/types";6import { getDisplayDomain } from "../../shared/domain";7import { sendMessage } from "../App";8import { VaultView } from "./Vault";910// Replaces the unmaintained `zxcvbn` package (LV-8; see Lock.tsx) — this file11// was missed by that migration. Health.tsx is already its own route-level12// lazy chunk, so a static import here carries the same load-timing as the13// dynamic imports used elsewhere; it just needs the dictionaries configured14// once before first use.15zxcvbnOptions.setOptions({16  translations: zxcvbnEn.translations,17  graphs: zxcvbnCommon.adjacencyGraphs,18  dictionary: { ...zxcvbnCommon.dictionary, ...zxcvbnEn.dictionary },19});2021function sha256(plain: string): Promise<string> {22  const encoder = new TextEncoder();23  const data = encoder.encode(plain);24  return crypto.subtle.digest("SHA-256", data).then((buf) => {25    return Array.from(new Uint8Array(buf))26      .map((b) => b.toString(16).padStart(2, "0"))27      .join("");28  });29}3031interface ReusedGroup {32  hash: string;33  entries: VaultEntry[];34}3536interface HealthProps {37  entries: VaultEntry[];38  isPro?: boolean;39  onFixEntry: (entry: VaultEntry) => void;40  onBack: () => void;41  onRefresh: () => void;42  /** Set by App while an entry edit is in progress so the view stays mounted43   *  but invisible — avoids re-running computeReused() on every back-nav. */44  hidden?: boolean;45}4647function formatExposure(count: number): string {48  if (count >= 1000) return `${(count / 1000).toFixed(count % 1000 === 0 ? 0 : 1)}k exposures`;49  return `${count.toLocaleString()} ${count === 1 ? "exposure" : "exposures"}`;50}5152function formatRelativeTime(ms: number): string {53  const seconds = Math.floor(ms / 1000);54  if (seconds < 60) return "just now";55  const minutes = Math.floor(seconds / 60);56  if (minutes < 60) return `${minutes} minute${minutes !== 1 ? "s" : ""} ago`;57  const hours = Math.floor(minutes / 60);58  if (hours < 24) return `${hours} hour${hours !== 1 ? "s" : ""} ago`;59  const days = Math.floor(hours / 24);60  if (days < 30) return `${days} day${days !== 1 ? "s" : ""} ago`;61  const months = Math.floor(days / 30);62  if (months < 12) return `${months} month${months !== 1 ? "s" : ""} ago`;63  const years = Math.floor(months / 12);64  return `${years} year${years !== 1 ? "s" : ""} ago`;65}6667function scoreLabel(score: number): string {68  const labels = ["very weak", "weak", "fair", "good", "strong"];69  return labels[score] ?? "unknown";70}7172export function HealthView(props: HealthProps) {73  const [checkingBreaches, setCheckingBreaches] = createSignal(false);74  const [reusedGroups, setReusedGroups] = createSignal<ReusedGroup[]>([]);75  const [reusedLoading, setReusedLoading] = createSignal(true);76  const [expandedSections, setExpandedSections] = createSignal<Record<string, boolean>>({77    breached: true,78    reused: true,79    weak: true,80    old: true,81    passkeys: true,82  });83  const [viewingReusedGroup, setViewingReusedGroup] = createSignal<VaultEntry[] | null>(null);84  const [advancedReport, setAdvancedReport] = createSignal<AdvancedHealthReport | null>(null);85  // Start true for Pro users so the loading screen is visible on the very first render —86  // previously initialized to false and flipped in onMount, which left a blank-body flash.87  const [advancedLoading, setAdvancedLoading] = createSignal<boolean>(!!props.isPro);8889  onMount(async () => {90    if (!props.isPro) return;91    try {92      const res = await sendMessage({ type: "GET_ADVANCED_HEALTH_REPORT" });93      if ((res as { ok: boolean }).ok) {94        setAdvancedReport((res as { ok: boolean; data?: AdvancedHealthReport }).data ?? null);95      }96    } finally {97      setAdvancedLoading(false);98    }99  });100101  const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;102103  const breachedEntries = createMemo(() =>104    props.entries105      .filter((e) => (e.breach ?? 0) > 0)106      .sort((a, b) => (b.breach ?? 0) - (a.breach ?? 0))107  );108109  const weakEntries = createMemo(() => {110    // Precompute scores once — zxcvbn is expensive, avoid calling it multiple times per entry111    const scored = props.entries112      .filter((e) => e.type !== "note" && e.password)113      .map((entry) => ({ entry, score: zxcvbn(entry.password!).score }))114      .filter(({ score }) => score <= 1);115    scored.sort((a, b) => a.score - b.score);116    return scored;117  });118119  const oldEntries = createMemo(() =>120    props.entries121      .filter((e) => e.type === "login" && e.modifiedAt < Date.now() - ONE_YEAR_MS)122      .sort((a, b) => a.modifiedAt - b.modifiedAt)123  );124125  const stalePasskeys = createMemo(() =>126    props.entries127      .filter((e) => e.passkey !== undefined)128      .map((entry) => {129        const passkey = entry.passkey!;130        const lastActivity = passkey.lastUsedAt ?? passkey.createdAt ?? entry.createdAt;131        const ageMs = Date.now() - lastActivity;132        const status =133          passkey.signCount === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000134            ? "Unused"135            : ageMs >= ONE_YEAR_MS136              ? "Old"137              : null;138        return { entry, lastActivity, status };139      })140      .filter((item) => item.status !== null)141      .sort((a, b) => a.lastActivity - b.lastActivity)142  );143144  const entriesById = createMemo(() => {145    const map = new Map<string, VaultEntry>();146    for (const entry of props.entries) map.set(entry.id, entry);147    return map;148  });149150  async function computeReused() {151    const loginEntries = props.entries.filter((e) => e.type !== "note" && e.password);152    if (loginEntries.length === 0) {153      setReusedGroups([]);154      setReusedLoading(false);155      return;156    }157    const hashes = await Promise.all(158      loginEntries.map(async (e) => ({ entry: e, hash: await sha256(e.password!) }))159    );160    const map = new Map<string, VaultEntry[]>();161    for (const { entry, hash } of hashes) {162      const group = map.get(hash) ?? [];163      group.push(entry);164      map.set(hash, group);165    }166    const groups: ReusedGroup[] = [];167    map.forEach((entries, hash) => {168      if (entries.length >= 2) {169        groups.push({ hash, entries });170      }171    });172    groups.sort((a, b) => b.entries.length - a.entries.length);173    setReusedGroups(groups);174    setReusedLoading(false);175  }176177  computeReused();178179  function toggleSection(key: string) {180    setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));181  }182183  async function handleCheckAllBreaches() {184    setCheckingBreaches(true);185    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });186    setCheckingBreaches(false);187    if ((res as { ok: boolean }).ok) {188      props.onRefresh();189    }190  }191192  const totalNeedsAttention = createMemo(193    () =>194      breachedEntries().length +195      reusedGroups().length +196      weakEntries().length +   // weakEntries returns { entry, score }[] now197      oldEntries().length +198      stalePasskeys().length199  );200201  const isLoading = () => reusedLoading() || (!!props.isPro && advancedLoading());202203  return (204    <>205      <div class="peach-overlay-view ov-enter-right" style={{ display: props.hidden ? "none" : "flex" }}>206        <div class="peach-overlay-header">207          <button208            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"209            onClick={props.onBack}210            aria-label="Back"211          >212            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">213              <path d="M6 1L1 6L6 11" />214            </svg>215          </button>216          <span class="peach-overlay-title">Advanced Vault Health</span>217          <div class="peach-overlay-spacer"></div>218        </div>219220        <div class="peach-health-body">221          <Show when={isLoading()}>222            <div class="peach-health-loading-screen" aria-busy="true" aria-live="polite">223              <div class="peach-health-loading-glyph" aria-hidden="true">224                <svg width="32" height="32" viewBox="0 0 32 32" fill="none">225                  <circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="1.2" opacity="0.18" />226                  <path d="M16 3a13 13 0 0 1 13 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">227                    <animateTransform attributeName="transform" type="rotate" from="0 16 16" to="360 16 16" dur="1.2s" repeatCount="indefinite" />228                  </path>229                </svg>230              </div>231              <h2 class="peach-health-loading-title">Checking your vault's health</h2>232              <p class="peach-health-loading-copy">233                Scanning for reused, breached, weak, and aging credentials.234                Just a moment…235              </p>236              <div class="peach-health-loading-track" aria-hidden="true">237                <div class="peach-health-loading-fill"></div>238              </div>239            </div>240          </Show>241          <Show when={!isLoading()}>242          <Show when={props.isPro && advancedReport() && advancedReport()!.remediation.length > 0}>243            <div class="peach-health-pro-queue">244              <div class="peach-health-pro-queue-head">245                <span class="peach-health-pro-queue-title">Remediation queue</span>246                <span class="peach-health-pro-queue-score">247                  Vault score {advancedReport()!.vaultScore}/100248                </span>249              </div>250              <For each={advancedReport()!.remediation.slice(0, 8)}>251                {(item) => {252                  const entry = () => entriesById().get(item.id);253                  return (254                    <div class="peach-health-pro-row">255                      <div class="peach-health-pro-row-info">256                        <span class="peach-health-pro-row-name">{item.title}</span>257                        <span class="peach-health-pro-row-meta">258                          {item.reasons.join(" · ")}259                        </span>260                        <div class="peach-health-pro-bar" aria-hidden="true">261                          <div262                            class="peach-health-pro-bar-fill"263                            style={{ width: `${Math.min(item.score, 100)}%` }}264                          ></div>265                        </div>266                      </div>267                      <Show when={entry()}>268                        {(target) => (269                          <button270                            class="peach-btn peach-btn--ghost peach-btn--sm peach-health-pro-action"271                            onClick={() => props.onFixEntry(target())}272                            aria-label={`Update password for ${item.title}`}273                          >274                            Update275                          </button>276                        )}277                      </Show>278                    </div>279                  );280                }}281              </For>282            </div>283          </Show>284285          <div class="peach-health-summary">286            <Show287              when={totalNeedsAttention() > 0}288              fallback={<span class="peach-health-summary-ok">Your vault looks healthy.</span>}289            >290              <span class="peach-health-summary-warn">291                {totalNeedsAttention()} {totalNeedsAttention() === 1 ? "entry needs" : "entries need"} attention292              </span>293            </Show>294          </div>295296          <div class="peach-health-check-row">297            <button298              class="peach-btn peach-btn--accent peach-btn--sm peach-health-check-btn"299              onClick={() => void handleCheckAllBreaches()}300              disabled={checkingBreaches()}301            >302              <svg width="10" height="12" viewBox="0 0 10 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" style="margin-right:5px;">303                <path d="M9 6L5 10L1 6" />304                <path d="M5 10V2" />305              </svg>306              {checkingBreaches() ? "checking..." : "Check all breaches now"}307            </button>308          </div>309310          <div class="peach-health-sections">311            <HealthSection312              title="Breached"313              count={breachedEntries().length}314              expanded={expandedSections().breached}315              onToggle={() => toggleSection("breached")}316              emptyMessage="No breached entries"317            >318              <For each={breachedEntries()}>319                {(entry) => (320                  <div class="peach-health-row">321                    <div class="peach-health-row-info">322                      <span class="peach-health-row-name">{entry.title}</span>323                      <span class="peach-health-row-meta" style="color:var(--peach-breach);">324                        {formatExposure(entry.breach ?? 0)}325                      </span>326                    </div>327                    <button328                      class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"329                      onClick={() => props.onFixEntry(entry)}330                    >331                      Fix332                      <svg width="6" height="10" viewBox="0 0 6 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" style="margin-left:4px;">333                        <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />334                      </svg>335                    </button>336                  </div>337                )}338              </For>339            </HealthSection>340341            <HealthSection342              title="Reused"343              count={reusedGroups().length}344              expanded={expandedSections().reused}345              onToggle={() => toggleSection("reused")}346              loading={reusedLoading()}347              emptyMessage="No reused passwords"348            >349              <Show when={!reusedLoading()} fallback={<div class="peach-health-loading">Computing...</div>}>350                <For each={reusedGroups()}>351                  {(group) => (352                    <div class="peach-health-row">353                      <div class="peach-health-row-info">354                        <span class="peach-health-row-name">355                          Used in {group.entries.length} places356                        </span>357                        <span class="peach-health-row-meta">358                          {group.entries.map((e) => e.title).join(", ")}359                        </span>360                      </div>361                      <button362                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"363                        onClick={() => setViewingReusedGroup(group.entries)}364                      >365                        View366                        <svg width="6" height="10" viewBox="0 0 6 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" style="margin-left:4px;">367                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />368                        </svg>369                      </button>370                    </div>371                  )}372                </For>373              </Show>374            </HealthSection>375376            <HealthSection377              title="Weak"378              count={weakEntries().length}379              expanded={expandedSections().weak}380              onToggle={() => toggleSection("weak")}381              emptyMessage="No weak passwords"382            >383              <For each={weakEntries()}>384                {({ entry, score }) => (385                    <div class="peach-health-row">386                      <div class="peach-health-row-info">387                        <span class="peach-health-row-name">388                          {entry.url ? getDisplayDomain(entry.url) : entry.title}389                        </span>390                        <span class="peach-health-row-meta">391                          score: {score} ({scoreLabel(score)})392                        </span>393                      </div>394                      <button395                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"396                        onClick={() => props.onFixEntry(entry)}397                      >398                        Fix399                        <svg width="6" height="10" viewBox="0 0 6 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" style="margin-left:4px;">400                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />401                        </svg>402                      </button>403                    </div>404                )}405              </For>406            </HealthSection>407408            <HealthSection409              title="Old"410              count={oldEntries().length}411              expanded={expandedSections().old}412              onToggle={() => toggleSection("old")}413              emptyMessage="No old entries"414            >415              <For each={oldEntries()}>416                {(entry) => {417                  const age = Date.now() - entry.modifiedAt;418                  return (419                    <div class="peach-health-row">420                      <div class="peach-health-row-info">421                        <span class="peach-health-row-name">422                          {entry.url ? getDisplayDomain(entry.url) : entry.title}423                        </span>424                        <span class="peach-health-row-meta">425                          last changed {formatRelativeTime(age)}426                        </span>427                      </div>428                      <button429                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"430                        onClick={() => props.onFixEntry(entry)}431                      >432                        Fix433                        <svg width="6" height="10" viewBox="0 0 6 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" style="margin-left:4px;">434                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />435                        </svg>436                      </button>437                    </div>438                  );439                }}440              </For>441            </HealthSection>442443            <HealthSection444              title="Passkeys"445              count={stalePasskeys().length}446              expanded={expandedSections().passkeys}447              onToggle={() => toggleSection("passkeys")}448              emptyMessage="No stale passkeys"449            >450              <For each={stalePasskeys()}>451                {(item) => (452                  <div class="peach-health-row">453                    <div class="peach-health-row-info">454                      <span class="peach-health-row-name">{item.entry.title}</span>455                      <span class="peach-health-row-meta">456                        {item.status} • {item.entry.passkey?.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}457                      </span>458                    </div>459                    <button460                      class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"461                      onClick={() => props.onFixEntry(item.entry)}462                    >463                      View464                      <svg width="6" height="10" viewBox="0 0 6 10" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round" style="margin-left:4px;">465                        <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />466                      </svg>467                    </button>468                  </div>469                )}470              </For>471            </HealthSection>472          </div>473          </Show>{/* end !isLoading() */}474        </div>475      </div>476477      <Show when={viewingReusedGroup()}>478        <VaultView479          entries={viewingReusedGroup()!}480          selectedEntryId={undefined}481          sortOrder="az"482          onSortOrderChange={() => {}}483          onSelectEntry={(entry) => {484            setViewingReusedGroup(null);485            props.onFixEntry(entry);486          }}487          onAdd={() => {}}488          onClose={() => setViewingReusedGroup(null)}489        />490      </Show>491    </>492  );493}494495interface HealthSectionProps {496  title: string;497  count: number;498  expanded: boolean;499  onToggle: () => void;500  loading?: boolean;501  emptyMessage: string;502  children: any;503}504505function HealthSection(props: HealthSectionProps) {506  return (507    <div class="peach-health-section">508      <button class="peach-health-section-header" onClick={props.onToggle}>509        <div class="peach-health-section-title-row">510          <span class="peach-health-count-badge">{props.count}</span>511          <span class="peach-health-section-title">{props.title}</span>512        </div>513        <svg514          class="peach-health-chevron"515          classList={{ "is-expanded": props.expanded }}516          width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"517        >518          <path d="M1 1L5 5L9 1" />519        </svg>520      </button>521      <Show when={props.expanded}>522        <div class="peach-health-section-body">523          <Show524            when={!props.loading && props.count > 0}525            fallback={526              <Show when={!props.loading}>527                <div class="peach-health-empty">{props.emptyMessage}</div>528              </Show>529            }530          >531            {props.children}532          </Show>533        </div>534      </Show>535    </div>536  );537}