Health.tsx

20.3 KB · raw

1import { createMemo, createSignal, For, onMount, Show } from "solid-js";2import { default as zxcvbn } from "zxcvbn";3import type { AdvancedHealthReport, VaultEntry } from "../../shared/types";4import { getDisplayDomain } from "../../shared/domain";5import { sendMessage } from "../App";6import { VaultView } from "./Vault";78function sha256(plain: string): Promise<string> {9  const encoder = new TextEncoder();10  const data = encoder.encode(plain);11  return crypto.subtle.digest("SHA-256", data).then((buf) => {12    return Array.from(new Uint8Array(buf))13      .map((b) => b.toString(16).padStart(2, "0"))14      .join("");15  });16}1718interface ReusedGroup {19  hash: string;20  entries: VaultEntry[];21}2223interface HealthProps {24  entries: VaultEntry[];25  isPro?: boolean;26  onFixEntry: (entry: VaultEntry) => void;27  onBack: () => void;28  onRefresh: () => void;29}3031function formatExposure(count: number): string {32  if (count >= 1000) return `${(count / 1000).toFixed(count % 1000 === 0 ? 0 : 1)}k exposures`;33  return `${count.toLocaleString()} ${count === 1 ? "exposure" : "exposures"}`;34}3536function formatRelativeTime(ms: number): string {37  const seconds = Math.floor(ms / 1000);38  if (seconds < 60) return "just now";39  const minutes = Math.floor(seconds / 60);40  if (minutes < 60) return `${minutes} minute${minutes !== 1 ? "s" : ""} ago`;41  const hours = Math.floor(minutes / 60);42  if (hours < 24) return `${hours} hour${hours !== 1 ? "s" : ""} ago`;43  const days = Math.floor(hours / 24);44  if (days < 30) return `${days} day${days !== 1 ? "s" : ""} ago`;45  const months = Math.floor(days / 30);46  if (months < 12) return `${months} month${months !== 1 ? "s" : ""} ago`;47  const years = Math.floor(months / 12);48  return `${years} year${years !== 1 ? "s" : ""} ago`;49}5051function scoreLabel(score: number): string {52  const labels = ["very weak", "weak", "fair", "good", "strong"];53  return labels[score] ?? "unknown";54}5556export function HealthView(props: HealthProps) {57  const [checkingBreaches, setCheckingBreaches] = createSignal(false);58  const [reusedGroups, setReusedGroups] = createSignal<ReusedGroup[]>([]);59  const [reusedLoading, setReusedLoading] = createSignal(true);60  const [expandedSections, setExpandedSections] = createSignal<Record<string, boolean>>({61    breached: true,62    reused: true,63    weak: true,64    old: true,65    passkeys: true,66  });67  const [viewingReusedGroup, setViewingReusedGroup] = createSignal<VaultEntry[] | null>(null);68  const [advancedReport, setAdvancedReport] = createSignal<AdvancedHealthReport | null>(null);69  // Start true for Pro users so the loading screen is visible on the very first render —70  // previously initialized to false and flipped in onMount, which left a blank-body flash.71  const [advancedLoading, setAdvancedLoading] = createSignal<boolean>(!!props.isPro);7273  onMount(async () => {74    if (!props.isPro) return;75    try {76      const res = await sendMessage({ type: "GET_ADVANCED_HEALTH_REPORT" });77      if ((res as { ok: boolean }).ok) {78        setAdvancedReport((res as { ok: boolean; data?: AdvancedHealthReport }).data ?? null);79      }80    } finally {81      setAdvancedLoading(false);82    }83  });8485  const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;8687  const breachedEntries = createMemo(() =>88    props.entries89      .filter((e) => (e.breach ?? 0) > 0)90      .sort((a, b) => (b.breach ?? 0) - (a.breach ?? 0))91  );9293  const weakEntries = createMemo(() => {94    // Precompute scores once — zxcvbn is expensive, avoid calling it multiple times per entry95    const scored = props.entries96      .filter((e) => e.type !== "note" && e.password)97      .map((entry) => ({ entry, score: zxcvbn(entry.password!).score }))98      .filter(({ score }) => score <= 1);99    scored.sort((a, b) => a.score - b.score);100    return scored;101  });102103  const oldEntries = createMemo(() =>104    props.entries105      .filter((e) => e.type === "login" && e.modifiedAt < Date.now() - ONE_YEAR_MS)106      .sort((a, b) => a.modifiedAt - b.modifiedAt)107  );108109  const stalePasskeys = createMemo(() =>110    props.entries111      .filter((e) => e.passkey !== undefined)112      .map((entry) => {113        const passkey = entry.passkey!;114        const lastActivity = passkey.lastUsedAt ?? passkey.createdAt ?? entry.createdAt;115        const ageMs = Date.now() - lastActivity;116        const status =117          passkey.signCount === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000118            ? "Unused"119            : ageMs >= ONE_YEAR_MS120              ? "Old"121              : null;122        return { entry, lastActivity, status };123      })124      .filter((item) => item.status !== null)125      .sort((a, b) => a.lastActivity - b.lastActivity)126  );127128  const entriesById = createMemo(() => {129    const map = new Map<string, VaultEntry>();130    for (const entry of props.entries) map.set(entry.id, entry);131    return map;132  });133134  async function computeReused() {135    const loginEntries = props.entries.filter((e) => e.type !== "note" && e.password);136    if (loginEntries.length === 0) {137      setReusedGroups([]);138      setReusedLoading(false);139      return;140    }141    const hashes = await Promise.all(142      loginEntries.map(async (e) => ({ entry: e, hash: await sha256(e.password!) }))143    );144    const map = new Map<string, VaultEntry[]>();145    for (const { entry, hash } of hashes) {146      const group = map.get(hash) ?? [];147      group.push(entry);148      map.set(hash, group);149    }150    const groups: ReusedGroup[] = [];151    map.forEach((entries, hash) => {152      if (entries.length >= 2) {153        groups.push({ hash, entries });154      }155    });156    groups.sort((a, b) => b.entries.length - a.entries.length);157    setReusedGroups(groups);158    setReusedLoading(false);159  }160161  computeReused();162163  function toggleSection(key: string) {164    setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));165  }166167  async function handleCheckAllBreaches() {168    setCheckingBreaches(true);169    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });170    setCheckingBreaches(false);171    if ((res as { ok: boolean }).ok) {172      props.onRefresh();173    }174  }175176  const totalNeedsAttention = createMemo(177    () =>178      breachedEntries().length +179      reusedGroups().length +180      weakEntries().length +   // weakEntries returns { entry, score }[] now181      oldEntries().length +182      stalePasskeys().length183  );184185  return (186    <>187      <div class="peach-overlay-view ov-enter-right" style="display:flex">188        <div class="peach-overlay-header">189          <button190            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"191            onClick={props.onBack}192            aria-label="Back"193          >194            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">195              <path d="M6 1L1 6L6 11" />196            </svg>197          </button>198          <span class="peach-overlay-title">vault health</span>199          <div class="peach-overlay-spacer"></div>200        </div>201202        <div class="peach-health-body">203          <Show when={props.isPro && advancedLoading()}>204            <div class="peach-health-loading-screen" aria-busy="true" aria-live="polite">205              <div class="peach-health-loading-glyph" aria-hidden="true">206                <svg width="32" height="32" viewBox="0 0 32 32" fill="none">207                  <circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="1.2" opacity="0.18" />208                  <path d="M16 3a13 13 0 0 1 13 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">209                    <animateTransform attributeName="transform" type="rotate" from="0 16 16" to="360 16 16" dur="1.2s" repeatCount="indefinite" />210                  </path>211                </svg>212              </div>213              <h2 class="peach-health-loading-title">Checking your vault's health</h2>214              <p class="peach-health-loading-copy">215                Scanning for reused, breached, weak, and aging credentials.216                Just a moment…217              </p>218              <div class="peach-health-loading-track" aria-hidden="true">219                <div class="peach-health-loading-fill"></div>220              </div>221            </div>222          </Show>223          <Show when={props.isPro && !advancedLoading() && advancedReport() && advancedReport()!.remediation.length > 0}>224            <div class="peach-health-pro-queue">225              <div class="peach-health-pro-queue-head">226                <span class="peach-health-pro-queue-title">Remediation queue</span>227                <span class="peach-health-pro-queue-score">228                  Vault score {advancedReport()!.vaultScore}/100229                </span>230              </div>231              <For each={advancedReport()!.remediation.slice(0, 8)}>232                {(item) => {233                  const entry = () => entriesById().get(item.id);234                  return (235                    <div class="peach-health-pro-row">236                      <div class="peach-health-pro-row-info">237                        <span class="peach-health-pro-row-name">{item.title}</span>238                        <span class="peach-health-pro-row-meta">239                          {item.reasons.join(" · ")}240                        </span>241                        <div class="peach-health-pro-bar" aria-hidden="true">242                          <div243                            class="peach-health-pro-bar-fill"244                            style={{ width: `${Math.min(item.score, 100)}%` }}245                          ></div>246                        </div>247                      </div>248                      <Show when={entry()}>249                        {(target) => (250                          <button251                            class="peach-btn peach-btn--ghost peach-btn--sm peach-health-pro-action"252                            onClick={() => props.onFixEntry(target())}253                            aria-label={`Update password for ${item.title}`}254                          >255                            Update256                          </button>257                        )}258                      </Show>259                    </div>260                  );261                }}262              </For>263            </div>264          </Show>265266          <div class="peach-health-summary">267            <Show268              when={totalNeedsAttention() > 0}269              fallback={<span class="peach-health-summary-ok">Your vault looks healthy.</span>}270            >271              <span class="peach-health-summary-warn">272                {totalNeedsAttention()} {totalNeedsAttention() === 1 ? "entry needs" : "entries need"} attention273              </span>274            </Show>275          </div>276277          <div class="peach-health-check-row">278            <button279              class="peach-btn peach-btn--accent peach-btn--sm peach-health-check-btn"280              onClick={() => void handleCheckAllBreaches()}281              disabled={checkingBreaches()}282            >283              <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;">284                <path d="M9 6L5 10L1 6" />285                <path d="M5 10V2" />286              </svg>287              {checkingBreaches() ? "checking..." : "Check all breaches now"}288            </button>289          </div>290291          <div class="peach-health-sections">292            <HealthSection293              title="Breached"294              count={breachedEntries().length}295              expanded={expandedSections().breached}296              onToggle={() => toggleSection("breached")}297              emptyMessage="No breached entries"298            >299              <For each={breachedEntries()}>300                {(entry) => (301                  <div class="peach-health-row">302                    <div class="peach-health-row-info">303                      <span class="peach-health-row-name">{entry.title}</span>304                      <span class="peach-health-row-meta" style="color:var(--peach-breach);">305                        {formatExposure(entry.breach ?? 0)}306                      </span>307                    </div>308                    <button309                      class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"310                      onClick={() => props.onFixEntry(entry)}311                    >312                      Fix313                      <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;">314                        <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />315                      </svg>316                    </button>317                  </div>318                )}319              </For>320            </HealthSection>321322            <HealthSection323              title="Reused"324              count={reusedGroups().length}325              expanded={expandedSections().reused}326              onToggle={() => toggleSection("reused")}327              loading={reusedLoading()}328              emptyMessage="No reused passwords"329            >330              <Show when={!reusedLoading()} fallback={<div class="peach-health-loading">Computing...</div>}>331                <For each={reusedGroups()}>332                  {(group) => (333                    <div class="peach-health-row">334                      <div class="peach-health-row-info">335                        <span class="peach-health-row-name">336                          Used in {group.entries.length} places337                        </span>338                        <span class="peach-health-row-meta">339                          {group.entries.map((e) => e.title).join(", ")}340                        </span>341                      </div>342                      <button343                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"344                        onClick={() => setViewingReusedGroup(group.entries)}345                      >346                        View347                        <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;">348                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />349                        </svg>350                      </button>351                    </div>352                  )}353                </For>354              </Show>355            </HealthSection>356357            <HealthSection358              title="Weak"359              count={weakEntries().length}360              expanded={expandedSections().weak}361              onToggle={() => toggleSection("weak")}362              emptyMessage="No weak passwords"363            >364              <For each={weakEntries()}>365                {({ entry, score }) => (366                    <div class="peach-health-row">367                      <div class="peach-health-row-info">368                        <span class="peach-health-row-name">369                          {entry.url ? getDisplayDomain(entry.url) : entry.title}370                        </span>371                        <span class="peach-health-row-meta">372                          score: {score} ({scoreLabel(score)})373                        </span>374                      </div>375                      <button376                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"377                        onClick={() => props.onFixEntry(entry)}378                      >379                        Fix380                        <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;">381                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />382                        </svg>383                      </button>384                    </div>385                )}386              </For>387            </HealthSection>388389            <HealthSection390              title="Old"391              count={oldEntries().length}392              expanded={expandedSections().old}393              onToggle={() => toggleSection("old")}394              emptyMessage="No old entries"395            >396              <For each={oldEntries()}>397                {(entry) => {398                  const age = Date.now() - entry.modifiedAt;399                  return (400                    <div class="peach-health-row">401                      <div class="peach-health-row-info">402                        <span class="peach-health-row-name">403                          {entry.url ? getDisplayDomain(entry.url) : entry.title}404                        </span>405                        <span class="peach-health-row-meta">406                          last changed {formatRelativeTime(age)}407                        </span>408                      </div>409                      <button410                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"411                        onClick={() => props.onFixEntry(entry)}412                      >413                        Fix414                        <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;">415                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />416                        </svg>417                      </button>418                    </div>419                  );420                }}421              </For>422            </HealthSection>423424            <HealthSection425              title="Passkeys"426              count={stalePasskeys().length}427              expanded={expandedSections().passkeys}428              onToggle={() => toggleSection("passkeys")}429              emptyMessage="No stale passkeys"430            >431              <For each={stalePasskeys()}>432                {(item) => (433                  <div class="peach-health-row">434                    <div class="peach-health-row-info">435                      <span class="peach-health-row-name">{item.entry.title}</span>436                      <span class="peach-health-row-meta">437                        {item.status} • {item.entry.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}438                      </span>439                    </div>440                    <button441                      class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"442                      onClick={() => props.onFixEntry(item.entry)}443                    >444                      View445                      <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;">446                        <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />447                      </svg>448                    </button>449                  </div>450                )}451              </For>452            </HealthSection>453          </div>454        </div>455      </div>456457      <Show when={viewingReusedGroup()}>458        <VaultView459          entries={viewingReusedGroup()!}460          selectedEntryId={undefined}461          sortOrder="az"462          onSortOrderChange={() => {}}463          onSelectEntry={(entry) => {464            setViewingReusedGroup(null);465            props.onFixEntry(entry);466          }}467          onAdd={() => {}}468          onClose={() => setViewingReusedGroup(null)}469        />470      </Show>471    </>472  );473}474475interface HealthSectionProps {476  title: string;477  count: number;478  expanded: boolean;479  onToggle: () => void;480  loading?: boolean;481  emptyMessage: string;482  children: any;483}484485function HealthSection(props: HealthSectionProps) {486  return (487    <div class="peach-health-section">488      <button class="peach-health-section-header" onClick={props.onToggle}>489        <div class="peach-health-section-title-row">490          <span class="peach-health-count-badge">{props.count}</span>491          <span class="peach-health-section-title">{props.title}</span>492        </div>493        <svg494          class="peach-health-chevron"495          classList={{ "is-expanded": props.expanded }}496          width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"497        >498          <path d="M1 1L5 5L9 1" />499        </svg>500      </button>501      <Show when={props.expanded}>502        <div class="peach-health-section-body">503          <Show504            when={!props.loading && props.count > 0}505            fallback={506              <Show when={!props.loading}>507                <div class="peach-health-empty">{props.emptyMessage}</div>508              </Show>509            }510          >511            {props.children}512          </Show>513        </div>514      </Show>515    </div>516  );517}