Health.tsx

19.5 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  async function computeReused() {129    const loginEntries = props.entries.filter((e) => e.type !== "note" && e.password);130    if (loginEntries.length === 0) {131      setReusedGroups([]);132      setReusedLoading(false);133      return;134    }135    const hashes = await Promise.all(136      loginEntries.map(async (e) => ({ entry: e, hash: await sha256(e.password!) }))137    );138    const map = new Map<string, VaultEntry[]>();139    for (const { entry, hash } of hashes) {140      const group = map.get(hash) ?? [];141      group.push(entry);142      map.set(hash, group);143    }144    const groups: ReusedGroup[] = [];145    map.forEach((entries, hash) => {146      if (entries.length >= 2) {147        groups.push({ hash, entries });148      }149    });150    groups.sort((a, b) => b.entries.length - a.entries.length);151    setReusedGroups(groups);152    setReusedLoading(false);153  }154155  computeReused();156157  function toggleSection(key: string) {158    setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));159  }160161  async function handleCheckAllBreaches() {162    setCheckingBreaches(true);163    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });164    setCheckingBreaches(false);165    if ((res as { ok: boolean }).ok) {166      props.onRefresh();167    }168  }169170  const totalNeedsAttention = createMemo(171    () =>172      breachedEntries().length +173      reusedGroups().length +174      weakEntries().length +   // weakEntries returns { entry, score }[] now175      oldEntries().length +176      stalePasskeys().length177  );178179  return (180    <>181      <div class="peach-overlay-view ov-enter-right" style="display:flex">182        <div class="peach-overlay-header">183          <button184            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"185            onClick={props.onBack}186            aria-label="Back"187          >188            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">189              <path d="M6 1L1 6L6 11" />190            </svg>191          </button>192          <span class="peach-overlay-title">vault health</span>193          <div class="peach-overlay-spacer"></div>194        </div>195196        <div class="peach-health-body">197          <Show when={props.isPro && advancedLoading()}>198            <div class="peach-health-loading-screen" aria-busy="true" aria-live="polite">199              <div class="peach-health-loading-glyph" aria-hidden="true">200                <svg width="32" height="32" viewBox="0 0 32 32" fill="none">201                  <circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="1.2" opacity="0.18" />202                  <path d="M16 3a13 13 0 0 1 13 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">203                    <animateTransform attributeName="transform" type="rotate" from="0 16 16" to="360 16 16" dur="1.2s" repeatCount="indefinite" />204                  </path>205                </svg>206              </div>207              <h2 class="peach-health-loading-title">Checking your vault's health</h2>208              <p class="peach-health-loading-copy">209                Scanning for reused, breached, weak, and aging credentials.210                Just a moment…211              </p>212              <div class="peach-health-loading-track" aria-hidden="true">213                <div class="peach-health-loading-fill"></div>214              </div>215            </div>216          </Show>217          <Show when={props.isPro && !advancedLoading() && advancedReport() && advancedReport()!.remediation.length > 0}>218            <div class="peach-health-pro-queue">219              <div class="peach-health-pro-queue-head">220                <span class="peach-health-pro-queue-title">Remediation queue</span>221                <span class="peach-health-pro-queue-score">222                  Vault score {advancedReport()!.vaultScore}/100223                </span>224              </div>225              <For each={advancedReport()!.remediation.slice(0, 8)}>226                {(item) => (227                  <div class="peach-health-pro-row">228                    <div class="peach-health-pro-row-info">229                      <span class="peach-health-pro-row-name">{item.title}</span>230                      <span class="peach-health-pro-row-meta">231                        {item.reasons.join(" · ")}232                      </span>233                      <div class="peach-health-pro-bar" aria-hidden="true">234                        <div235                          class="peach-health-pro-bar-fill"236                          style={{ width: `${Math.min(item.score, 100)}%` }}237                        ></div>238                      </div>239                    </div>240                  </div>241                )}242              </For>243            </div>244          </Show>245246          <div class="peach-health-summary">247            <Show248              when={totalNeedsAttention() > 0}249              fallback={<span class="peach-health-summary-ok">Your vault looks healthy.</span>}250            >251              <span class="peach-health-summary-warn">252                {totalNeedsAttention()} {totalNeedsAttention() === 1 ? "entry needs" : "entries need"} attention253              </span>254            </Show>255          </div>256257          <div class="peach-health-check-row">258            <button259              class="peach-btn peach-btn--accent peach-btn--sm peach-health-check-btn"260              onClick={() => void handleCheckAllBreaches()}261              disabled={checkingBreaches()}262            >263              <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;">264                <path d="M9 6L5 10L1 6" />265                <path d="M5 10V2" />266              </svg>267              {checkingBreaches() ? "checking..." : "Check all breaches now"}268            </button>269          </div>270271          <div class="peach-health-sections">272            <HealthSection273              title="Breached"274              count={breachedEntries().length}275              expanded={expandedSections().breached}276              onToggle={() => toggleSection("breached")}277              emptyMessage="No breached entries"278            >279              <For each={breachedEntries()}>280                {(entry) => (281                  <div class="peach-health-row">282                    <div class="peach-health-row-info">283                      <span class="peach-health-row-name">{entry.title}</span>284                      <span class="peach-health-row-meta" style="color:var(--peach-breach);">285                        {formatExposure(entry.breach ?? 0)}286                      </span>287                    </div>288                    <button289                      class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"290                      onClick={() => props.onFixEntry(entry)}291                    >292                      Fix293                      <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;">294                        <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />295                      </svg>296                    </button>297                  </div>298                )}299              </For>300            </HealthSection>301302            <HealthSection303              title="Reused"304              count={reusedGroups().length}305              expanded={expandedSections().reused}306              onToggle={() => toggleSection("reused")}307              loading={reusedLoading()}308              emptyMessage="No reused passwords"309            >310              <Show when={!reusedLoading()} fallback={<div class="peach-health-loading">Computing...</div>}>311                <For each={reusedGroups()}>312                  {(group) => (313                    <div class="peach-health-row">314                      <div class="peach-health-row-info">315                        <span class="peach-health-row-name">316                          Used in {group.entries.length} places317                        </span>318                        <span class="peach-health-row-meta">319                          {group.entries.map((e) => e.title).join(", ")}320                        </span>321                      </div>322                      <button323                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"324                        onClick={() => setViewingReusedGroup(group.entries)}325                      >326                        View327                        <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;">328                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />329                        </svg>330                      </button>331                    </div>332                  )}333                </For>334              </Show>335            </HealthSection>336337            <HealthSection338              title="Weak"339              count={weakEntries().length}340              expanded={expandedSections().weak}341              onToggle={() => toggleSection("weak")}342              emptyMessage="No weak passwords"343            >344              <For each={weakEntries()}>345                {({ entry, score }) => (346                    <div class="peach-health-row">347                      <div class="peach-health-row-info">348                        <span class="peach-health-row-name">349                          {entry.url ? getDisplayDomain(entry.url) : entry.title}350                        </span>351                        <span class="peach-health-row-meta">352                          score: {score} ({scoreLabel(score)})353                        </span>354                      </div>355                      <button356                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"357                        onClick={() => props.onFixEntry(entry)}358                      >359                        Fix360                        <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;">361                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />362                        </svg>363                      </button>364                    </div>365                )}366              </For>367            </HealthSection>368369            <HealthSection370              title="Old"371              count={oldEntries().length}372              expanded={expandedSections().old}373              onToggle={() => toggleSection("old")}374              emptyMessage="No old entries"375            >376              <For each={oldEntries()}>377                {(entry) => {378                  const age = Date.now() - entry.modifiedAt;379                  return (380                    <div class="peach-health-row">381                      <div class="peach-health-row-info">382                        <span class="peach-health-row-name">383                          {entry.url ? getDisplayDomain(entry.url) : entry.title}384                        </span>385                        <span class="peach-health-row-meta">386                          last changed {formatRelativeTime(age)}387                        </span>388                      </div>389                      <button390                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"391                        onClick={() => props.onFixEntry(entry)}392                      >393                        Fix394                        <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;">395                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />396                        </svg>397                      </button>398                    </div>399                  );400                }}401              </For>402            </HealthSection>403404            <HealthSection405              title="Passkeys"406              count={stalePasskeys().length}407              expanded={expandedSections().passkeys}408              onToggle={() => toggleSection("passkeys")}409              emptyMessage="No stale passkeys"410            >411              <For each={stalePasskeys()}>412                {(item) => (413                  <div class="peach-health-row">414                    <div class="peach-health-row-info">415                      <span class="peach-health-row-name">{item.entry.title}</span>416                      <span class="peach-health-row-meta">417                        {item.status} • {item.entry.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}418                      </span>419                    </div>420                    <button421                      class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"422                      onClick={() => props.onFixEntry(item.entry)}423                    >424                      View425                      <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;">426                        <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />427                      </svg>428                    </button>429                  </div>430                )}431              </For>432            </HealthSection>433          </div>434        </div>435      </div>436437      <Show when={viewingReusedGroup()}>438        <VaultView439          entries={viewingReusedGroup()!}440          selectedEntryId={undefined}441          sortOrder="az"442          onSortOrderChange={() => {}}443          onSelectEntry={(entry) => {444            setViewingReusedGroup(null);445            props.onFixEntry(entry);446          }}447          onAdd={() => {}}448          onClose={() => setViewingReusedGroup(null)}449        />450      </Show>451    </>452  );453}454455interface HealthSectionProps {456  title: string;457  count: number;458  expanded: boolean;459  onToggle: () => void;460  loading?: boolean;461  emptyMessage: string;462  children: any;463}464465function HealthSection(props: HealthSectionProps) {466  return (467    <div class="peach-health-section">468      <button class="peach-health-section-header" onClick={props.onToggle}>469        <div class="peach-health-section-title-row">470          <span class="peach-health-count-badge">{props.count}</span>471          <span class="peach-health-section-title">{props.title}</span>472        </div>473        <svg474          class="peach-health-chevron"475          classList={{ "is-expanded": props.expanded }}476          width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"477        >478          <path d="M1 1L5 5L9 1" />479        </svg>480      </button>481      <Show when={props.expanded}>482        <div class="peach-health-section-body">483          <Show484            when={!props.loading && props.count > 0}485            fallback={486              <Show when={!props.loading}>487                <div class="peach-health-empty">{props.emptyMessage}</div>488              </Show>489            }490          >491            {props.children}492          </Show>493        </div>494      </Show>495    </div>496  );497}