Health.tsx

18.8 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.type === "passkey")121      .map((entry) => {122        const lastActivity = entry.lastUsedAt ?? entry.createdAt;123        const ageMs = Date.now() - lastActivity;124        const status =125          (entry.signCount ?? 0) === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000126            ? "Unused"127            : ageMs >= ONE_YEAR_MS128              ? "Old"129              : null;130        return { entry, lastActivity, status };131      })132      .filter((item) => item.status !== null)133      .sort((a, b) => a.lastActivity - b.lastActivity)134  );135136  async function computeReused() {137    const loginEntries = props.entries.filter((e) => e.type !== "note" && e.password);138    if (loginEntries.length === 0) {139      setReusedGroups([]);140      setReusedLoading(false);141      return;142    }143    const hashes = await Promise.all(144      loginEntries.map(async (e) => ({ entry: e, hash: await sha256(e.password!) }))145    );146    const map = new Map<string, VaultEntry[]>();147    for (const { entry, hash } of hashes) {148      const group = map.get(hash) ?? [];149      group.push(entry);150      map.set(hash, group);151    }152    const groups: ReusedGroup[] = [];153    map.forEach((entries, hash) => {154      if (entries.length >= 2) {155        groups.push({ hash, entries });156      }157    });158    groups.sort((a, b) => b.entries.length - a.entries.length);159    setReusedGroups(groups);160    setReusedLoading(false);161  }162163  computeReused();164165  function toggleSection(key: string) {166    setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));167  }168169  async function handleCheckAllBreaches() {170    setCheckingBreaches(true);171    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });172    setCheckingBreaches(false);173    if ((res as { ok: boolean }).ok) {174      props.onRefresh();175    }176  }177178  const totalNeedsAttention = createMemo(179    () =>180      breachedEntries().length +181      reusedGroups().length +182      weakEntries().length +   // weakEntries returns { entry, score }[] now183      oldEntries().length +184      stalePasskeys().length185  );186187  return (188    <>189      <div class="peach-overlay-view ov-enter-right" style="display:flex">190        <div class="peach-overlay-header">191          <button192            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"193            onClick={props.onBack}194            aria-label="Back"195          >196            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">197              <path d="M6 1L1 6L6 11" />198            </svg>199          </button>200          <span class="peach-overlay-title">vault health</span>201          <div class="peach-overlay-spacer"></div>202        </div>203204        <div class="peach-health-body">205          <Show when={props.isPro && advancedReport() && advancedReport()!.remediation.length > 0}>206            <div class="peach-health-pro-queue">207              <div class="peach-health-pro-queue-head">208                <span class="peach-health-pro-queue-title">Remediation queue</span>209                <span class="peach-health-pro-queue-score">210                  Vault score {advancedReport()!.vaultScore}/100211                </span>212              </div>213              <For each={advancedReport()!.remediation.slice(0, 8)}>214                {(item) => (215                  <div class="peach-health-pro-row">216                    <div class="peach-health-pro-row-info">217                      <span class="peach-health-pro-row-name">{item.title}</span>218                      <span class="peach-health-pro-row-meta">219                        {item.reasons.join(" · ")}220                      </span>221                      <div class="peach-health-pro-bar" aria-hidden="true">222                        <div223                          class="peach-health-pro-bar-fill"224                          style={{ width: `${Math.min(item.score, 100)}%` }}225                        ></div>226                      </div>227                    </div>228                    <button229                      class="peach-btn peach-btn--accent peach-btn--sm"230                      onClick={() => void handleSmartChange(item.id)}231                      disabled={smartChangingId() === item.id}232                    >233                      {smartChangingId() === item.id ? "opening…" : "Change this password"}234                    </button>235                  </div>236                )}237              </For>238            </div>239          </Show>240241          <div class="peach-health-summary">242            <Show243              when={totalNeedsAttention() > 0}244              fallback={<span class="peach-health-summary-ok">Your vault looks healthy.</span>}245            >246              <span class="peach-health-summary-warn">247                {totalNeedsAttention()} {totalNeedsAttention() === 1 ? "entry needs" : "entries need"} attention248              </span>249            </Show>250          </div>251252          <div class="peach-health-check-row">253            <button254              class="peach-btn peach-btn--accent peach-btn--sm peach-health-check-btn"255              onClick={() => void handleCheckAllBreaches()}256              disabled={checkingBreaches()}257            >258              <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;">259                <path d="M9 6L5 10L1 6" />260                <path d="M5 10V2" />261              </svg>262              {checkingBreaches() ? "checking..." : "Check all breaches now"}263            </button>264          </div>265266          <div class="peach-health-sections">267            <HealthSection268              title="Breached"269              count={breachedEntries().length}270              expanded={expandedSections().breached}271              onToggle={() => toggleSection("breached")}272              emptyMessage="No breached entries"273            >274              <For each={breachedEntries()}>275                {(entry) => (276                  <div class="peach-health-row">277                    <div class="peach-health-row-info">278                      <span class="peach-health-row-name">{entry.title}</span>279                      <span class="peach-health-row-meta" style="color:var(--peach-breach);">280                        {formatExposure(entry.breach ?? 0)}281                      </span>282                    </div>283                    <button284                      class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"285                      onClick={() => props.onFixEntry(entry)}286                    >287                      Fix288                      <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;">289                        <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />290                      </svg>291                    </button>292                  </div>293                )}294              </For>295            </HealthSection>296297            <HealthSection298              title="Reused"299              count={reusedGroups().length}300              expanded={expandedSections().reused}301              onToggle={() => toggleSection("reused")}302              loading={reusedLoading()}303              emptyMessage="No reused passwords"304            >305              <Show when={!reusedLoading()} fallback={<div class="peach-health-loading">Computing...</div>}>306                <For each={reusedGroups()}>307                  {(group) => (308                    <div class="peach-health-row">309                      <div class="peach-health-row-info">310                        <span class="peach-health-row-name">311                          Used in {group.entries.length} places312                        </span>313                        <span class="peach-health-row-meta">314                          {group.entries.map((e) => e.title).join(", ")}315                        </span>316                      </div>317                      <button318                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"319                        onClick={() => setViewingReusedGroup(group.entries)}320                      >321                        View322                        <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;">323                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />324                        </svg>325                      </button>326                    </div>327                  )}328                </For>329              </Show>330            </HealthSection>331332            <HealthSection333              title="Weak"334              count={weakEntries().length}335              expanded={expandedSections().weak}336              onToggle={() => toggleSection("weak")}337              emptyMessage="No weak passwords"338            >339              <For each={weakEntries()}>340                {({ entry, score }) => (341                    <div class="peach-health-row">342                      <div class="peach-health-row-info">343                        <span class="peach-health-row-name">344                          {entry.url ? getDisplayDomain(entry.url) : entry.title}345                        </span>346                        <span class="peach-health-row-meta">347                          score: {score} ({scoreLabel(score)})348                        </span>349                      </div>350                      <button351                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"352                        onClick={() => props.onFixEntry(entry)}353                      >354                        Fix355                        <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;">356                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />357                        </svg>358                      </button>359                    </div>360                )}361              </For>362            </HealthSection>363364            <HealthSection365              title="Old"366              count={oldEntries().length}367              expanded={expandedSections().old}368              onToggle={() => toggleSection("old")}369              emptyMessage="No old entries"370            >371              <For each={oldEntries()}>372                {(entry) => {373                  const age = Date.now() - entry.modifiedAt;374                  return (375                    <div class="peach-health-row">376                      <div class="peach-health-row-info">377                        <span class="peach-health-row-name">378                          {entry.url ? getDisplayDomain(entry.url) : entry.title}379                        </span>380                        <span class="peach-health-row-meta">381                          last changed {formatRelativeTime(age)}382                        </span>383                      </div>384                      <button385                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"386                        onClick={() => props.onFixEntry(entry)}387                      >388                        Fix389                        <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;">390                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />391                        </svg>392                      </button>393                    </div>394                  );395                }}396              </For>397            </HealthSection>398399            <HealthSection400              title="Passkeys"401              count={stalePasskeys().length}402              expanded={expandedSections().passkeys}403              onToggle={() => toggleSection("passkeys")}404              emptyMessage="No stale passkeys"405            >406              <For each={stalePasskeys()}>407                {(item) => (408                  <div class="peach-health-row">409                    <div class="peach-health-row-info">410                      <span class="peach-health-row-name">{item.entry.title}</span>411                      <span class="peach-health-row-meta">412                        {item.status} • {item.entry.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}413                      </span>414                    </div>415                    <button416                      class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"417                      onClick={() => props.onFixEntry(item.entry)}418                    >419                      View420                      <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;">421                        <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />422                      </svg>423                    </button>424                  </div>425                )}426              </For>427            </HealthSection>428          </div>429        </div>430      </div>431432      <Show when={viewingReusedGroup()}>433        <VaultView434          entries={viewingReusedGroup()!}435          selectedEntryId={undefined}436          sortOrder="az"437          onSortOrderChange={() => {}}438          onSelectEntry={(entry) => {439            setViewingReusedGroup(null);440            props.onFixEntry(entry);441          }}442          onAdd={() => {}}443          onClose={() => setViewingReusedGroup(null)}444        />445      </Show>446    </>447  );448}449450interface HealthSectionProps {451  title: string;452  count: number;453  expanded: boolean;454  onToggle: () => void;455  loading?: boolean;456  emptyMessage: string;457  children: any;458}459460function HealthSection(props: HealthSectionProps) {461  return (462    <div class="peach-health-section">463      <button class="peach-health-section-header" onClick={props.onToggle}>464        <div class="peach-health-section-title-row">465          <span class="peach-health-count-badge">{props.count}</span>466          <span class="peach-health-section-title">{props.title}</span>467        </div>468        <svg469          class="peach-health-chevron"470          classList={{ "is-expanded": props.expanded }}471          width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"472        >473          <path d="M1 1L5 5L9 1" />474        </svg>475      </button>476      <Show when={props.expanded}>477        <div class="peach-health-section-body">478          <Show479            when={!props.loading && props.count > 0}480            fallback={481              <Show when={!props.loading}>482                <div class="peach-health-empty">{props.emptyMessage}</div>483              </Show>484            }485          >486            {props.children}487          </Show>488        </div>489      </Show>490    </div>491  );492}