Health.tsx

20.6 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);72  const [smartChangingId, setSmartChangingId] = createSignal<string | null>(null);7374  onMount(async () => {75    if (!props.isPro) return;76    try {77      const res = await sendMessage({ type: "GET_ADVANCED_HEALTH_REPORT" });78      if ((res as { ok: boolean }).ok) {79        setAdvancedReport((res as { ok: boolean; data?: AdvancedHealthReport }).data ?? null);80      }81    } finally {82      setAdvancedLoading(false);83    }84  });8586  async function handleSmartChange(entryId: string) {87    setSmartChangingId(entryId);88    const res = await sendMessage({89      type: "INITIATE_PASSWORD_CHANGE",90      payload: { id: entryId },91    }) as { ok: boolean; error?: string };92    setSmartChangingId(null);93    if (res.ok) {94      // The reset URL opened in a new tab — close the popup so the user can95      // act on the page. Refresh the report on next mount so the remediation96      // queue reflects any updates the user makes.97      window.close();98    } else if (res.error === "no_reset_url") {99      // Entry has no usable URL — open the entry directly for manual editing.100      props.onFixEntry(props.entries.find((e) => e.id === entryId)!);101    }102  }103104  const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;105106  const breachedEntries = createMemo(() =>107    props.entries108      .filter((e) => (e.breach ?? 0) > 0)109      .sort((a, b) => (b.breach ?? 0) - (a.breach ?? 0))110  );111112  const weakEntries = createMemo(() => {113    // Precompute scores once — zxcvbn is expensive, avoid calling it multiple times per entry114    const scored = props.entries115      .filter((e) => e.type !== "note" && e.password)116      .map((entry) => ({ entry, score: zxcvbn(entry.password!).score }))117      .filter(({ score }) => score <= 1);118    scored.sort((a, b) => a.score - b.score);119    return scored;120  });121122  const oldEntries = createMemo(() =>123    props.entries124      .filter((e) => e.type === "login" && e.modifiedAt < Date.now() - ONE_YEAR_MS)125      .sort((a, b) => a.modifiedAt - b.modifiedAt)126  );127128  const stalePasskeys = createMemo(() =>129    props.entries130      .filter((e) => e.passkey !== undefined)131      .map((entry) => {132        const passkey = entry.passkey!;133        const lastActivity = passkey.lastUsedAt ?? passkey.createdAt ?? entry.createdAt;134        const ageMs = Date.now() - lastActivity;135        const status =136          passkey.signCount === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000137            ? "Unused"138            : ageMs >= ONE_YEAR_MS139              ? "Old"140              : null;141        return { entry, lastActivity, status };142      })143      .filter((item) => item.status !== null)144      .sort((a, b) => a.lastActivity - b.lastActivity)145  );146147  async function computeReused() {148    const loginEntries = props.entries.filter((e) => e.type !== "note" && e.password);149    if (loginEntries.length === 0) {150      setReusedGroups([]);151      setReusedLoading(false);152      return;153    }154    const hashes = await Promise.all(155      loginEntries.map(async (e) => ({ entry: e, hash: await sha256(e.password!) }))156    );157    const map = new Map<string, VaultEntry[]>();158    for (const { entry, hash } of hashes) {159      const group = map.get(hash) ?? [];160      group.push(entry);161      map.set(hash, group);162    }163    const groups: ReusedGroup[] = [];164    map.forEach((entries, hash) => {165      if (entries.length >= 2) {166        groups.push({ hash, entries });167      }168    });169    groups.sort((a, b) => b.entries.length - a.entries.length);170    setReusedGroups(groups);171    setReusedLoading(false);172  }173174  computeReused();175176  function toggleSection(key: string) {177    setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));178  }179180  async function handleCheckAllBreaches() {181    setCheckingBreaches(true);182    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });183    setCheckingBreaches(false);184    if ((res as { ok: boolean }).ok) {185      props.onRefresh();186    }187  }188189  const totalNeedsAttention = createMemo(190    () =>191      breachedEntries().length +192      reusedGroups().length +193      weakEntries().length +   // weakEntries returns { entry, score }[] now194      oldEntries().length +195      stalePasskeys().length196  );197198  return (199    <>200      <div class="peach-overlay-view ov-enter-right" style="display:flex">201        <div class="peach-overlay-header">202          <button203            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"204            onClick={props.onBack}205            aria-label="Back"206          >207            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">208              <path d="M6 1L1 6L6 11" />209            </svg>210          </button>211          <span class="peach-overlay-title">vault health</span>212          <div class="peach-overlay-spacer"></div>213        </div>214215        <div class="peach-health-body">216          <Show when={props.isPro && advancedLoading()}>217            <div class="peach-health-loading-screen" aria-busy="true" aria-live="polite">218              <div class="peach-health-loading-glyph" aria-hidden="true">219                <svg width="32" height="32" viewBox="0 0 32 32" fill="none">220                  <circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="1.2" opacity="0.18" />221                  <path d="M16 3a13 13 0 0 1 13 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">222                    <animateTransform attributeName="transform" type="rotate" from="0 16 16" to="360 16 16" dur="1.2s" repeatCount="indefinite" />223                  </path>224                </svg>225              </div>226              <h2 class="peach-health-loading-title">Checking your vault's health</h2>227              <p class="peach-health-loading-copy">228                Scanning for reused, breached, weak, and aging credentials.229                Just a moment…230              </p>231              <div class="peach-health-loading-track" aria-hidden="true">232                <div class="peach-health-loading-fill"></div>233              </div>234            </div>235          </Show>236          <Show when={props.isPro && !advancedLoading() && advancedReport() && advancedReport()!.remediation.length > 0}>237            <div class="peach-health-pro-queue">238              <div class="peach-health-pro-queue-head">239                <span class="peach-health-pro-queue-title">Remediation queue</span>240                <span class="peach-health-pro-queue-score">241                  Vault score {advancedReport()!.vaultScore}/100242                </span>243              </div>244              <For each={advancedReport()!.remediation.slice(0, 8)}>245                {(item) => (246                  <div class="peach-health-pro-row">247                    <div class="peach-health-pro-row-info">248                      <span class="peach-health-pro-row-name">{item.title}</span>249                      <span class="peach-health-pro-row-meta">250                        {item.reasons.join(" · ")}251                      </span>252                      <div class="peach-health-pro-bar" aria-hidden="true">253                        <div254                          class="peach-health-pro-bar-fill"255                          style={{ width: `${Math.min(item.score, 100)}%` }}256                        ></div>257                      </div>258                    </div>259                    <button260                      class="peach-btn peach-btn--accent peach-btn--sm"261                      onClick={() => void handleSmartChange(item.id)}262                      disabled={smartChangingId() === item.id}263                    >264                      {smartChangingId() === item.id ? "opening…" : "Change this password"}265                    </button>266                  </div>267                )}268              </For>269            </div>270          </Show>271272          <div class="peach-health-summary">273            <Show274              when={totalNeedsAttention() > 0}275              fallback={<span class="peach-health-summary-ok">Your vault looks healthy.</span>}276            >277              <span class="peach-health-summary-warn">278                {totalNeedsAttention()} {totalNeedsAttention() === 1 ? "entry needs" : "entries need"} attention279              </span>280            </Show>281          </div>282283          <div class="peach-health-check-row">284            <button285              class="peach-btn peach-btn--accent peach-btn--sm peach-health-check-btn"286              onClick={() => void handleCheckAllBreaches()}287              disabled={checkingBreaches()}288            >289              <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;">290                <path d="M9 6L5 10L1 6" />291                <path d="M5 10V2" />292              </svg>293              {checkingBreaches() ? "checking..." : "Check all breaches now"}294            </button>295          </div>296297          <div class="peach-health-sections">298            <HealthSection299              title="Breached"300              count={breachedEntries().length}301              expanded={expandedSections().breached}302              onToggle={() => toggleSection("breached")}303              emptyMessage="No breached entries"304            >305              <For each={breachedEntries()}>306                {(entry) => (307                  <div class="peach-health-row">308                    <div class="peach-health-row-info">309                      <span class="peach-health-row-name">{entry.title}</span>310                      <span class="peach-health-row-meta" style="color:var(--peach-breach);">311                        {formatExposure(entry.breach ?? 0)}312                      </span>313                    </div>314                    <button315                      class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"316                      onClick={() => props.onFixEntry(entry)}317                    >318                      Fix319                      <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;">320                        <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />321                      </svg>322                    </button>323                  </div>324                )}325              </For>326            </HealthSection>327328            <HealthSection329              title="Reused"330              count={reusedGroups().length}331              expanded={expandedSections().reused}332              onToggle={() => toggleSection("reused")}333              loading={reusedLoading()}334              emptyMessage="No reused passwords"335            >336              <Show when={!reusedLoading()} fallback={<div class="peach-health-loading">Computing...</div>}>337                <For each={reusedGroups()}>338                  {(group) => (339                    <div class="peach-health-row">340                      <div class="peach-health-row-info">341                        <span class="peach-health-row-name">342                          Used in {group.entries.length} places343                        </span>344                        <span class="peach-health-row-meta">345                          {group.entries.map((e) => e.title).join(", ")}346                        </span>347                      </div>348                      <button349                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"350                        onClick={() => setViewingReusedGroup(group.entries)}351                      >352                        View353                        <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;">354                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />355                        </svg>356                      </button>357                    </div>358                  )}359                </For>360              </Show>361            </HealthSection>362363            <HealthSection364              title="Weak"365              count={weakEntries().length}366              expanded={expandedSections().weak}367              onToggle={() => toggleSection("weak")}368              emptyMessage="No weak passwords"369            >370              <For each={weakEntries()}>371                {({ entry, score }) => (372                    <div class="peach-health-row">373                      <div class="peach-health-row-info">374                        <span class="peach-health-row-name">375                          {entry.url ? getDisplayDomain(entry.url) : entry.title}376                        </span>377                        <span class="peach-health-row-meta">378                          score: {score} ({scoreLabel(score)})379                        </span>380                      </div>381                      <button382                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"383                        onClick={() => props.onFixEntry(entry)}384                      >385                        Fix386                        <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;">387                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />388                        </svg>389                      </button>390                    </div>391                )}392              </For>393            </HealthSection>394395            <HealthSection396              title="Old"397              count={oldEntries().length}398              expanded={expandedSections().old}399              onToggle={() => toggleSection("old")}400              emptyMessage="No old entries"401            >402              <For each={oldEntries()}>403                {(entry) => {404                  const age = Date.now() - entry.modifiedAt;405                  return (406                    <div class="peach-health-row">407                      <div class="peach-health-row-info">408                        <span class="peach-health-row-name">409                          {entry.url ? getDisplayDomain(entry.url) : entry.title}410                        </span>411                        <span class="peach-health-row-meta">412                          last changed {formatRelativeTime(age)}413                        </span>414                      </div>415                      <button416                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"417                        onClick={() => props.onFixEntry(entry)}418                      >419                        Fix420                        <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                }}427              </For>428            </HealthSection>429430            <HealthSection431              title="Passkeys"432              count={stalePasskeys().length}433              expanded={expandedSections().passkeys}434              onToggle={() => toggleSection("passkeys")}435              emptyMessage="No stale passkeys"436            >437              <For each={stalePasskeys()}>438                {(item) => (439                  <div class="peach-health-row">440                    <div class="peach-health-row-info">441                      <span class="peach-health-row-name">{item.entry.title}</span>442                      <span class="peach-health-row-meta">443                        {item.status} • {item.entry.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}444                      </span>445                    </div>446                    <button447                      class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"448                      onClick={() => props.onFixEntry(item.entry)}449                    >450                      View451                      <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;">452                        <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />453                      </svg>454                    </button>455                  </div>456                )}457              </For>458            </HealthSection>459          </div>460        </div>461      </div>462463      <Show when={viewingReusedGroup()}>464        <VaultView465          entries={viewingReusedGroup()!}466          selectedEntryId={undefined}467          sortOrder="az"468          onSortOrderChange={() => {}}469          onSelectEntry={(entry) => {470            setViewingReusedGroup(null);471            props.onFixEntry(entry);472          }}473          onAdd={() => {}}474          onClose={() => setViewingReusedGroup(null)}475        />476      </Show>477    </>478  );479}480481interface HealthSectionProps {482  title: string;483  count: number;484  expanded: boolean;485  onToggle: () => void;486  loading?: boolean;487  emptyMessage: string;488  children: any;489}490491function HealthSection(props: HealthSectionProps) {492  return (493    <div class="peach-health-section">494      <button class="peach-health-section-header" onClick={props.onToggle}>495        <div class="peach-health-section-title-row">496          <span class="peach-health-count-badge">{props.count}</span>497          <span class="peach-health-section-title">{props.title}</span>498        </div>499        <svg500          class="peach-health-chevron"501          classList={{ "is-expanded": props.expanded }}502          width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"503        >504          <path d="M1 1L5 5L9 1" />505        </svg>506      </button>507      <Show when={props.expanded}>508        <div class="peach-health-section-body">509          <Show510            when={!props.loading && props.count > 0}511            fallback={512              <Show when={!props.loading}>513                <div class="peach-health-empty">{props.emptyMessage}</div>514              </Show>515            }516          >517            {props.children}518          </Show>519        </div>520      </Show>521    </div>522  );523}