Health.tsx

20.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 [advancedLoading, setAdvancedLoading] = createSignal<boolean>(false);70  const [smartChangingId, setSmartChangingId] = createSignal<string | null>(null);7172  onMount(async () => {73    if (!props.isPro) return;74    setAdvancedLoading(true);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  async function handleSmartChange(entryId: string) {86    setSmartChangingId(entryId);87    const res = await sendMessage({88      type: "INITIATE_PASSWORD_CHANGE",89      payload: { id: entryId },90    });91    setSmartChangingId(null);92    if ((res as { ok: boolean }).ok) {93      // The reset URL opened in a new tab — close the popup so the user can94      // act on the page. Refresh the report on next mount so the remediation95      // queue reflects any updates the user makes.96      window.close();97    }98  }99100  const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;101102  const breachedEntries = createMemo(() =>103    props.entries104      .filter((e) => (e.breach ?? 0) > 0)105      .sort((a, b) => (b.breach ?? 0) - (a.breach ?? 0))106  );107108  const weakEntries = createMemo(() => {109    // Precompute scores once — zxcvbn is expensive, avoid calling it multiple times per entry110    const scored = props.entries111      .filter((e) => e.type !== "note" && e.password)112      .map((entry) => ({ entry, score: zxcvbn(entry.password!).score }))113      .filter(({ score }) => score <= 1);114    scored.sort((a, b) => a.score - b.score);115    return scored;116  });117118  const oldEntries = createMemo(() =>119    props.entries120      .filter((e) => e.type === "login" && e.modifiedAt < Date.now() - ONE_YEAR_MS)121      .sort((a, b) => a.modifiedAt - b.modifiedAt)122  );123124  const stalePasskeys = createMemo(() =>125    props.entries126      .filter((e) => e.passkey !== undefined)127      .map((entry) => {128        const passkey = entry.passkey!;129        const lastActivity = passkey.lastUsedAt ?? passkey.createdAt ?? entry.createdAt;130        const ageMs = Date.now() - lastActivity;131        const status =132          passkey.signCount === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000133            ? "Unused"134            : ageMs >= ONE_YEAR_MS135              ? "Old"136              : null;137        return { entry, lastActivity, status };138      })139      .filter((item) => item.status !== null)140      .sort((a, b) => a.lastActivity - b.lastActivity)141  );142143  async function computeReused() {144    const loginEntries = props.entries.filter((e) => e.type !== "note" && e.password);145    if (loginEntries.length === 0) {146      setReusedGroups([]);147      setReusedLoading(false);148      return;149    }150    const hashes = await Promise.all(151      loginEntries.map(async (e) => ({ entry: e, hash: await sha256(e.password!) }))152    );153    const map = new Map<string, VaultEntry[]>();154    for (const { entry, hash } of hashes) {155      const group = map.get(hash) ?? [];156      group.push(entry);157      map.set(hash, group);158    }159    const groups: ReusedGroup[] = [];160    map.forEach((entries, hash) => {161      if (entries.length >= 2) {162        groups.push({ hash, entries });163      }164    });165    groups.sort((a, b) => b.entries.length - a.entries.length);166    setReusedGroups(groups);167    setReusedLoading(false);168  }169170  computeReused();171172  function toggleSection(key: string) {173    setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));174  }175176  async function handleCheckAllBreaches() {177    setCheckingBreaches(true);178    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });179    setCheckingBreaches(false);180    if ((res as { ok: boolean }).ok) {181      props.onRefresh();182    }183  }184185  const totalNeedsAttention = createMemo(186    () =>187      breachedEntries().length +188      reusedGroups().length +189      weakEntries().length +   // weakEntries returns { entry, score }[] now190      oldEntries().length +191      stalePasskeys().length192  );193194  return (195    <>196      <div class="peach-overlay-view ov-enter-right" style="display:flex">197        <div class="peach-overlay-header">198          <button199            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"200            onClick={props.onBack}201            aria-label="Back"202          >203            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">204              <path d="M6 1L1 6L6 11" />205            </svg>206          </button>207          <span class="peach-overlay-title">vault health</span>208          <div class="peach-overlay-spacer"></div>209        </div>210211        <div class="peach-health-body">212          <Show when={props.isPro && advancedLoading()}>213            <div class="peach-health-skeleton" aria-busy="true" aria-live="polite">214              <div class="peach-health-skeleton-head">215                <span class="peach-health-skeleton-title">Remediation queue</span>216                <span class="peach-health-skeleton-meta">217                  <span class="peach-health-skeleton-dot" aria-hidden="true"></span>218                  Scanning vault…219                </span>220              </div>221              <div class="peach-health-skeleton-row">222                <div>223                  <div class="peach-health-skeleton-bar peach-health-skeleton-bar--name"></div>224                  <div class="peach-health-skeleton-bar peach-health-skeleton-bar--meta"></div>225                </div>226                <div class="peach-health-skeleton-bar peach-health-skeleton-bar--cta"></div>227              </div>228              <div class="peach-health-skeleton-row">229                <div>230                  <div class="peach-health-skeleton-bar peach-health-skeleton-bar--name"></div>231                  <div class="peach-health-skeleton-bar peach-health-skeleton-bar--meta"></div>232                </div>233                <div class="peach-health-skeleton-bar peach-health-skeleton-bar--cta"></div>234              </div>235              <div class="peach-health-skeleton-row">236                <div>237                  <div class="peach-health-skeleton-bar peach-health-skeleton-bar--name"></div>238                  <div class="peach-health-skeleton-bar peach-health-skeleton-bar--meta"></div>239                </div>240                <div class="peach-health-skeleton-bar peach-health-skeleton-bar--cta"></div>241              </div>242            </div>243          </Show>244          <Show when={props.isPro && !advancedLoading() && advancedReport() && advancedReport()!.remediation.length > 0}>245            <div class="peach-health-pro-queue">246              <div class="peach-health-pro-queue-head">247                <span class="peach-health-pro-queue-title">Remediation queue</span>248                <span class="peach-health-pro-queue-score">249                  Vault score {advancedReport()!.vaultScore}/100250                </span>251              </div>252              <For each={advancedReport()!.remediation.slice(0, 8)}>253                {(item) => (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                    <button268                      class="peach-btn peach-btn--accent peach-btn--sm"269                      onClick={() => void handleSmartChange(item.id)}270                      disabled={smartChangingId() === item.id}271                    >272                      {smartChangingId() === item.id ? "opening…" : "Change this password"}273                    </button>274                  </div>275                )}276              </For>277            </div>278          </Show>279280          <div class="peach-health-summary">281            <Show282              when={totalNeedsAttention() > 0}283              fallback={<span class="peach-health-summary-ok">Your vault looks healthy.</span>}284            >285              <span class="peach-health-summary-warn">286                {totalNeedsAttention()} {totalNeedsAttention() === 1 ? "entry needs" : "entries need"} attention287              </span>288            </Show>289          </div>290291          <div class="peach-health-check-row">292            <button293              class="peach-btn peach-btn--accent peach-btn--sm peach-health-check-btn"294              onClick={() => void handleCheckAllBreaches()}295              disabled={checkingBreaches()}296            >297              <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;">298                <path d="M9 6L5 10L1 6" />299                <path d="M5 10V2" />300              </svg>301              {checkingBreaches() ? "checking..." : "Check all breaches now"}302            </button>303          </div>304305          <div class="peach-health-sections">306            <HealthSection307              title="Breached"308              count={breachedEntries().length}309              expanded={expandedSections().breached}310              onToggle={() => toggleSection("breached")}311              emptyMessage="No breached entries"312            >313              <For each={breachedEntries()}>314                {(entry) => (315                  <div class="peach-health-row">316                    <div class="peach-health-row-info">317                      <span class="peach-health-row-name">{entry.title}</span>318                      <span class="peach-health-row-meta" style="color:var(--peach-breach);">319                        {formatExposure(entry.breach ?? 0)}320                      </span>321                    </div>322                    <button323                      class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"324                      onClick={() => props.onFixEntry(entry)}325                    >326                      Fix327                      <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            </HealthSection>335336            <HealthSection337              title="Reused"338              count={reusedGroups().length}339              expanded={expandedSections().reused}340              onToggle={() => toggleSection("reused")}341              loading={reusedLoading()}342              emptyMessage="No reused passwords"343            >344              <Show when={!reusedLoading()} fallback={<div class="peach-health-loading">Computing...</div>}>345                <For each={reusedGroups()}>346                  {(group) => (347                    <div class="peach-health-row">348                      <div class="peach-health-row-info">349                        <span class="peach-health-row-name">350                          Used in {group.entries.length} places351                        </span>352                        <span class="peach-health-row-meta">353                          {group.entries.map((e) => e.title).join(", ")}354                        </span>355                      </div>356                      <button357                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"358                        onClick={() => setViewingReusedGroup(group.entries)}359                      >360                        View361                        <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;">362                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />363                        </svg>364                      </button>365                    </div>366                  )}367                </For>368              </Show>369            </HealthSection>370371            <HealthSection372              title="Weak"373              count={weakEntries().length}374              expanded={expandedSections().weak}375              onToggle={() => toggleSection("weak")}376              emptyMessage="No weak passwords"377            >378              <For each={weakEntries()}>379                {({ entry, score }) => (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                          score: {score} ({scoreLabel(score)})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              </For>401            </HealthSection>402403            <HealthSection404              title="Old"405              count={oldEntries().length}406              expanded={expandedSections().old}407              onToggle={() => toggleSection("old")}408              emptyMessage="No old entries"409            >410              <For each={oldEntries()}>411                {(entry) => {412                  const age = Date.now() - entry.modifiedAt;413                  return (414                    <div class="peach-health-row">415                      <div class="peach-health-row-info">416                        <span class="peach-health-row-name">417                          {entry.url ? getDisplayDomain(entry.url) : entry.title}418                        </span>419                        <span class="peach-health-row-meta">420                          last changed {formatRelativeTime(age)}421                        </span>422                      </div>423                      <button424                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"425                        onClick={() => props.onFixEntry(entry)}426                      >427                        Fix428                        <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;">429                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />430                        </svg>431                      </button>432                    </div>433                  );434                }}435              </For>436            </HealthSection>437438            <HealthSection439              title="Passkeys"440              count={stalePasskeys().length}441              expanded={expandedSections().passkeys}442              onToggle={() => toggleSection("passkeys")}443              emptyMessage="No stale passkeys"444            >445              <For each={stalePasskeys()}>446                {(item) => (447                  <div class="peach-health-row">448                    <div class="peach-health-row-info">449                      <span class="peach-health-row-name">{item.entry.title}</span>450                      <span class="peach-health-row-meta">451                        {item.status} • {item.entry.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}452                      </span>453                    </div>454                    <button455                      class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"456                      onClick={() => props.onFixEntry(item.entry)}457                    >458                      View459                      <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;">460                        <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />461                      </svg>462                    </button>463                  </div>464                )}465              </For>466            </HealthSection>467          </div>468        </div>469      </div>470471      <Show when={viewingReusedGroup()}>472        <VaultView473          entries={viewingReusedGroup()!}474          selectedEntryId={undefined}475          sortOrder="az"476          onSortOrderChange={() => {}}477          onSelectEntry={(entry) => {478            setViewingReusedGroup(null);479            props.onFixEntry(entry);480          }}481          onAdd={() => {}}482          onClose={() => setViewingReusedGroup(null)}483        />484      </Show>485    </>486  );487}488489interface HealthSectionProps {490  title: string;491  count: number;492  expanded: boolean;493  onToggle: () => void;494  loading?: boolean;495  emptyMessage: string;496  children: any;497}498499function HealthSection(props: HealthSectionProps) {500  return (501    <div class="peach-health-section">502      <button class="peach-health-section-header" onClick={props.onToggle}>503        <div class="peach-health-section-title-row">504          <span class="peach-health-count-badge">{props.count}</span>505          <span class="peach-health-section-title">{props.title}</span>506        </div>507        <svg508          class="peach-health-chevron"509          classList={{ "is-expanded": props.expanded }}510          width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"511        >512          <path d="M1 1L5 5L9 1" />513        </svg>514      </button>515      <Show when={props.expanded}>516        <div class="peach-health-section-body">517          <Show518            when={!props.loading && props.count > 0}519            fallback={520              <Show when={!props.loading}>521                <div class="peach-health-empty">{props.emptyMessage}</div>522              </Show>523            }524          >525            {props.children}526          </Show>527        </div>528      </Show>529    </div>530  );531}