Health.tsx

18.9 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  const [smartChangingId, setSmartChangingId] = createSignal<string | null>(null);7071  onMount(async () => {72    if (!props.isPro) return;73    const res = await sendMessage({ type: "GET_ADVANCED_HEALTH_REPORT" });74    if ((res as { ok: boolean }).ok) {75      setAdvancedReport((res as { ok: boolean; data?: AdvancedHealthReport }).data ?? null);76    }77  });7879  async function handleSmartChange(entryId: string) {80    setSmartChangingId(entryId);81    const res = await sendMessage({82      type: "INITIATE_PASSWORD_CHANGE",83      payload: { id: entryId },84    });85    setSmartChangingId(null);86    if ((res as { ok: boolean }).ok) {87      // The reset URL opened in a new tab — close the popup so the user can88      // act on the page. Refresh the report on next mount so the remediation89      // queue reflects any updates the user makes.90      window.close();91    }92  }9394  const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;9596  const breachedEntries = createMemo(() =>97    props.entries98      .filter((e) => (e.breach ?? 0) > 0)99      .sort((a, b) => (b.breach ?? 0) - (a.breach ?? 0))100  );101102  const weakEntries = createMemo(() => {103    // Precompute scores once — zxcvbn is expensive, avoid calling it multiple times per entry104    const scored = props.entries105      .filter((e) => e.type !== "note" && e.password)106      .map((entry) => ({ entry, score: zxcvbn(entry.password!).score }))107      .filter(({ score }) => score <= 1);108    scored.sort((a, b) => a.score - b.score);109    return scored;110  });111112  const oldEntries = createMemo(() =>113    props.entries114      .filter((e) => e.type === "login" && e.modifiedAt < Date.now() - ONE_YEAR_MS)115      .sort((a, b) => a.modifiedAt - b.modifiedAt)116  );117118  const stalePasskeys = createMemo(() =>119    props.entries120      .filter((e) => e.passkey !== undefined)121      .map((entry) => {122        const passkey = entry.passkey!;123        const lastActivity = passkey.lastUsedAt ?? passkey.createdAt ?? entry.createdAt;124        const ageMs = Date.now() - lastActivity;125        const status =126          passkey.signCount === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000127            ? "Unused"128            : ageMs >= ONE_YEAR_MS129              ? "Old"130              : null;131        return { entry, lastActivity, status };132      })133      .filter((item) => item.status !== null)134      .sort((a, b) => a.lastActivity - b.lastActivity)135  );136137  async function computeReused() {138    const loginEntries = props.entries.filter((e) => e.type !== "note" && e.password);139    if (loginEntries.length === 0) {140      setReusedGroups([]);141      setReusedLoading(false);142      return;143    }144    const hashes = await Promise.all(145      loginEntries.map(async (e) => ({ entry: e, hash: await sha256(e.password!) }))146    );147    const map = new Map<string, VaultEntry[]>();148    for (const { entry, hash } of hashes) {149      const group = map.get(hash) ?? [];150      group.push(entry);151      map.set(hash, group);152    }153    const groups: ReusedGroup[] = [];154    map.forEach((entries, hash) => {155      if (entries.length >= 2) {156        groups.push({ hash, entries });157      }158    });159    groups.sort((a, b) => b.entries.length - a.entries.length);160    setReusedGroups(groups);161    setReusedLoading(false);162  }163164  computeReused();165166  function toggleSection(key: string) {167    setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));168  }169170  async function handleCheckAllBreaches() {171    setCheckingBreaches(true);172    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });173    setCheckingBreaches(false);174    if ((res as { ok: boolean }).ok) {175      props.onRefresh();176    }177  }178179  const totalNeedsAttention = createMemo(180    () =>181      breachedEntries().length +182      reusedGroups().length +183      weakEntries().length +   // weakEntries returns { entry, score }[] now184      oldEntries().length +185      stalePasskeys().length186  );187188  return (189    <>190      <div class="peach-overlay-view ov-enter-right" style="display:flex">191        <div class="peach-overlay-header">192          <button193            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"194            onClick={props.onBack}195            aria-label="Back"196          >197            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">198              <path d="M6 1L1 6L6 11" />199            </svg>200          </button>201          <span class="peach-overlay-title">vault health</span>202          <div class="peach-overlay-spacer"></div>203        </div>204205        <div class="peach-health-body">206          <Show when={props.isPro && advancedReport() && advancedReport()!.remediation.length > 0}>207            <div class="peach-health-pro-queue">208              <div class="peach-health-pro-queue-head">209                <span class="peach-health-pro-queue-title">Remediation queue</span>210                <span class="peach-health-pro-queue-score">211                  Vault score {advancedReport()!.vaultScore}/100212                </span>213              </div>214              <For each={advancedReport()!.remediation.slice(0, 8)}>215                {(item) => (216                  <div class="peach-health-pro-row">217                    <div class="peach-health-pro-row-info">218                      <span class="peach-health-pro-row-name">{item.title}</span>219                      <span class="peach-health-pro-row-meta">220                        {item.reasons.join(" · ")}221                      </span>222                      <div class="peach-health-pro-bar" aria-hidden="true">223                        <div224                          class="peach-health-pro-bar-fill"225                          style={{ width: `${Math.min(item.score, 100)}%` }}226                        ></div>227                      </div>228                    </div>229                    <button230                      class="peach-btn peach-btn--accent peach-btn--sm"231                      onClick={() => void handleSmartChange(item.id)}232                      disabled={smartChangingId() === item.id}233                    >234                      {smartChangingId() === item.id ? "opening…" : "Change this password"}235                    </button>236                  </div>237                )}238              </For>239            </div>240          </Show>241242          <div class="peach-health-summary">243            <Show244              when={totalNeedsAttention() > 0}245              fallback={<span class="peach-health-summary-ok">Your vault looks healthy.</span>}246            >247              <span class="peach-health-summary-warn">248                {totalNeedsAttention()} {totalNeedsAttention() === 1 ? "entry needs" : "entries need"} attention249              </span>250            </Show>251          </div>252253          <div class="peach-health-check-row">254            <button255              class="peach-btn peach-btn--accent peach-btn--sm peach-health-check-btn"256              onClick={() => void handleCheckAllBreaches()}257              disabled={checkingBreaches()}258            >259              <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;">260                <path d="M9 6L5 10L1 6" />261                <path d="M5 10V2" />262              </svg>263              {checkingBreaches() ? "checking..." : "Check all breaches now"}264            </button>265          </div>266267          <div class="peach-health-sections">268            <HealthSection269              title="Breached"270              count={breachedEntries().length}271              expanded={expandedSections().breached}272              onToggle={() => toggleSection("breached")}273              emptyMessage="No breached entries"274            >275              <For each={breachedEntries()}>276                {(entry) => (277                  <div class="peach-health-row">278                    <div class="peach-health-row-info">279                      <span class="peach-health-row-name">{entry.title}</span>280                      <span class="peach-health-row-meta" style="color:var(--peach-breach);">281                        {formatExposure(entry.breach ?? 0)}282                      </span>283                    </div>284                    <button285                      class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"286                      onClick={() => props.onFixEntry(entry)}287                    >288                      Fix289                      <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;">290                        <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />291                      </svg>292                    </button>293                  </div>294                )}295              </For>296            </HealthSection>297298            <HealthSection299              title="Reused"300              count={reusedGroups().length}301              expanded={expandedSections().reused}302              onToggle={() => toggleSection("reused")}303              loading={reusedLoading()}304              emptyMessage="No reused passwords"305            >306              <Show when={!reusedLoading()} fallback={<div class="peach-health-loading">Computing...</div>}>307                <For each={reusedGroups()}>308                  {(group) => (309                    <div class="peach-health-row">310                      <div class="peach-health-row-info">311                        <span class="peach-health-row-name">312                          Used in {group.entries.length} places313                        </span>314                        <span class="peach-health-row-meta">315                          {group.entries.map((e) => e.title).join(", ")}316                        </span>317                      </div>318                      <button319                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"320                        onClick={() => setViewingReusedGroup(group.entries)}321                      >322                        View323                        <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;">324                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />325                        </svg>326                      </button>327                    </div>328                  )}329                </For>330              </Show>331            </HealthSection>332333            <HealthSection334              title="Weak"335              count={weakEntries().length}336              expanded={expandedSections().weak}337              onToggle={() => toggleSection("weak")}338              emptyMessage="No weak passwords"339            >340              <For each={weakEntries()}>341                {({ entry, score }) => (342                    <div class="peach-health-row">343                      <div class="peach-health-row-info">344                        <span class="peach-health-row-name">345                          {entry.url ? getDisplayDomain(entry.url) : entry.title}346                        </span>347                        <span class="peach-health-row-meta">348                          score: {score} ({scoreLabel(score)})349                        </span>350                      </div>351                      <button352                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"353                        onClick={() => props.onFixEntry(entry)}354                      >355                        Fix356                        <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;">357                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />358                        </svg>359                      </button>360                    </div>361                )}362              </For>363            </HealthSection>364365            <HealthSection366              title="Old"367              count={oldEntries().length}368              expanded={expandedSections().old}369              onToggle={() => toggleSection("old")}370              emptyMessage="No old entries"371            >372              <For each={oldEntries()}>373                {(entry) => {374                  const age = Date.now() - entry.modifiedAt;375                  return (376                    <div class="peach-health-row">377                      <div class="peach-health-row-info">378                        <span class="peach-health-row-name">379                          {entry.url ? getDisplayDomain(entry.url) : entry.title}380                        </span>381                        <span class="peach-health-row-meta">382                          last changed {formatRelativeTime(age)}383                        </span>384                      </div>385                      <button386                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"387                        onClick={() => props.onFixEntry(entry)}388                      >389                        Fix390                        <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;">391                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />392                        </svg>393                      </button>394                    </div>395                  );396                }}397              </For>398            </HealthSection>399400            <HealthSection401              title="Passkeys"402              count={stalePasskeys().length}403              expanded={expandedSections().passkeys}404              onToggle={() => toggleSection("passkeys")}405              emptyMessage="No stale passkeys"406            >407              <For each={stalePasskeys()}>408                {(item) => (409                  <div class="peach-health-row">410                    <div class="peach-health-row-info">411                      <span class="peach-health-row-name">{item.entry.title}</span>412                      <span class="peach-health-row-meta">413                        {item.status} • {item.entry.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}414                      </span>415                    </div>416                    <button417                      class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"418                      onClick={() => props.onFixEntry(item.entry)}419                    >420                      View421                      <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;">422                        <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />423                      </svg>424                    </button>425                  </div>426                )}427              </For>428            </HealthSection>429          </div>430        </div>431      </div>432433      <Show when={viewingReusedGroup()}>434        <VaultView435          entries={viewingReusedGroup()!}436          selectedEntryId={undefined}437          sortOrder="az"438          onSortOrderChange={() => {}}439          onSelectEntry={(entry) => {440            setViewingReusedGroup(null);441            props.onFixEntry(entry);442          }}443          onAdd={() => {}}444          onClose={() => setViewingReusedGroup(null)}445        />446      </Show>447    </>448  );449}450451interface HealthSectionProps {452  title: string;453  count: number;454  expanded: boolean;455  onToggle: () => void;456  loading?: boolean;457  emptyMessage: string;458  children: any;459}460461function HealthSection(props: HealthSectionProps) {462  return (463    <div class="peach-health-section">464      <button class="peach-health-section-header" onClick={props.onToggle}>465        <div class="peach-health-section-title-row">466          <span class="peach-health-count-badge">{props.count}</span>467          <span class="peach-health-section-title">{props.title}</span>468        </div>469        <svg470          class="peach-health-chevron"471          classList={{ "is-expanded": props.expanded }}472          width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"473        >474          <path d="M1 1L5 5L9 1" />475        </svg>476      </button>477      <Show when={props.expanded}>478        <div class="peach-health-section-body">479          <Show480            when={!props.loading && props.count > 0}481            fallback={482              <Show when={!props.loading}>483                <div class="peach-health-empty">{props.emptyMessage}</div>484              </Show>485            }486          >487            {props.children}488          </Show>489        </div>490      </Show>491    </div>492  );493}