Health.tsx

16.2 KB · raw

1import { createMemo, createSignal, For, Show } from "solid-js";2import { default as zxcvbn } from "zxcvbn";3import type { 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  onFixEntry: (entry: VaultEntry) => void;26  onBack: () => void;27  onRefresh: () => void;28}2930function formatExposure(count: number): string {31  if (count >= 1000) return `${(count / 1000).toFixed(count % 1000 === 0 ? 0 : 1)}k exposures`;32  return `${count.toLocaleString()} ${count === 1 ? "exposure" : "exposures"}`;33}3435function formatRelativeTime(ms: number): string {36  const seconds = Math.floor(ms / 1000);37  if (seconds < 60) return "just now";38  const minutes = Math.floor(seconds / 60);39  if (minutes < 60) return `${minutes} minute${minutes !== 1 ? "s" : ""} ago`;40  const hours = Math.floor(minutes / 60);41  if (hours < 24) return `${hours} hour${hours !== 1 ? "s" : ""} ago`;42  const days = Math.floor(hours / 24);43  if (days < 30) return `${days} day${days !== 1 ? "s" : ""} ago`;44  const months = Math.floor(days / 30);45  if (months < 12) return `${months} month${months !== 1 ? "s" : ""} ago`;46  const years = Math.floor(months / 12);47  return `${years} year${years !== 1 ? "s" : ""} ago`;48}4950function scoreLabel(score: number): string {51  const labels = ["very weak", "weak", "fair", "good", "strong"];52  return labels[score] ?? "unknown";53}5455export function HealthView(props: HealthProps) {56  const [checkingBreaches, setCheckingBreaches] = createSignal(false);57  const [reusedGroups, setReusedGroups] = createSignal<ReusedGroup[]>([]);58  const [reusedLoading, setReusedLoading] = createSignal(true);59  const [expandedSections, setExpandedSections] = createSignal<Record<string, boolean>>({60    breached: true,61    reused: true,62    weak: true,63    old: true,64    passkeys: true,65  });66  const [viewingReusedGroup, setViewingReusedGroup] = createSignal<VaultEntry[] | null>(null);6768  const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;6970  const breachedEntries = createMemo(() =>71    props.entries72      .filter((e) => (e.breach ?? 0) > 0)73      .sort((a, b) => (b.breach ?? 0) - (a.breach ?? 0))74  );7576  const weakEntries = createMemo(() => {77    // Precompute scores once — zxcvbn is expensive, avoid calling it multiple times per entry78    const scored = props.entries79      .filter((e) => e.type !== "note" && e.password)80      .map((entry) => ({ entry, score: zxcvbn(entry.password!).score }))81      .filter(({ score }) => score <= 1);82    scored.sort((a, b) => a.score - b.score);83    return scored;84  });8586  const oldEntries = createMemo(() =>87    props.entries88      .filter((e) => e.type === "login" && e.modifiedAt < Date.now() - ONE_YEAR_MS)89      .sort((a, b) => a.modifiedAt - b.modifiedAt)90  );9192  const stalePasskeys = createMemo(() =>93    props.entries94      .filter((e) => e.type === "passkey")95      .map((entry) => {96        const lastActivity = entry.lastUsedAt ?? entry.createdAt;97        const ageMs = Date.now() - lastActivity;98        const status =99          (entry.signCount ?? 0) === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000100            ? "Unused"101            : ageMs >= ONE_YEAR_MS102              ? "Old"103              : null;104        return { entry, lastActivity, status };105      })106      .filter((item) => item.status !== null)107      .sort((a, b) => a.lastActivity - b.lastActivity)108  );109110  async function computeReused() {111    const loginEntries = props.entries.filter((e) => e.type !== "note" && e.password);112    if (loginEntries.length === 0) {113      setReusedGroups([]);114      setReusedLoading(false);115      return;116    }117    const hashes = await Promise.all(118      loginEntries.map(async (e) => ({ entry: e, hash: await sha256(e.password!) }))119    );120    const map = new Map<string, VaultEntry[]>();121    for (const { entry, hash } of hashes) {122      const group = map.get(hash) ?? [];123      group.push(entry);124      map.set(hash, group);125    }126    const groups: ReusedGroup[] = [];127    map.forEach((entries, hash) => {128      if (entries.length >= 2) {129        groups.push({ hash, entries });130      }131    });132    groups.sort((a, b) => b.entries.length - a.entries.length);133    setReusedGroups(groups);134    setReusedLoading(false);135  }136137  computeReused();138139  function toggleSection(key: string) {140    setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));141  }142143  async function handleCheckAllBreaches() {144    setCheckingBreaches(true);145    const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });146    setCheckingBreaches(false);147    if ((res as { ok: boolean }).ok) {148      props.onRefresh();149    }150  }151152  const totalNeedsAttention = createMemo(153    () =>154      breachedEntries().length +155      reusedGroups().length +156      weakEntries().length +   // weakEntries returns { entry, score }[] now157      oldEntries().length +158      stalePasskeys().length159  );160161  return (162    <>163      <div class="peach-overlay-view ov-enter-right" style="display:flex">164        <div class="peach-overlay-header">165          <button166            class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"167            onClick={props.onBack}168            aria-label="Back"169          >170            <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">171              <path d="M6 1L1 6L6 11" />172            </svg>173          </button>174          <span class="peach-overlay-title">vault health</span>175          <div class="peach-overlay-spacer"></div>176        </div>177178        <div class="peach-health-body">179          <div class="peach-health-summary">180            <Show181              when={totalNeedsAttention() > 0}182              fallback={<span class="peach-health-summary-ok">Your vault looks healthy.</span>}183            >184              <span class="peach-health-summary-warn">185                {totalNeedsAttention()} {totalNeedsAttention() === 1 ? "entry needs" : "entries need"} attention186              </span>187            </Show>188          </div>189190          <div class="peach-health-check-row">191            <button192              class="peach-btn peach-btn--accent peach-btn--sm peach-health-check-btn"193              onClick={() => void handleCheckAllBreaches()}194              disabled={checkingBreaches()}195            >196              <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;">197                <path d="M9 6L5 10L1 6" />198                <path d="M5 10V2" />199              </svg>200              {checkingBreaches() ? "checking..." : "Check all breaches now"}201            </button>202          </div>203204          <div class="peach-health-sections">205            <HealthSection206              title="Breached"207              count={breachedEntries().length}208              expanded={expandedSections().breached}209              onToggle={() => toggleSection("breached")}210              emptyMessage="No breached entries"211            >212              <For each={breachedEntries()}>213                {(entry) => (214                  <div class="peach-health-row">215                    <div class="peach-health-row-info">216                      <span class="peach-health-row-name">{entry.title}</span>217                      <span class="peach-health-row-meta" style="color:var(--peach-breach);">218                        {formatExposure(entry.breach ?? 0)}219                      </span>220                    </div>221                    <button222                      class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"223                      onClick={() => props.onFixEntry(entry)}224                    >225                      Fix226                      <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;">227                        <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />228                      </svg>229                    </button>230                  </div>231                )}232              </For>233            </HealthSection>234235            <HealthSection236              title="Reused"237              count={reusedGroups().length}238              expanded={expandedSections().reused}239              onToggle={() => toggleSection("reused")}240              loading={reusedLoading()}241              emptyMessage="No reused passwords"242            >243              <Show when={!reusedLoading()} fallback={<div class="peach-health-loading">Computing...</div>}>244                <For each={reusedGroups()}>245                  {(group) => (246                    <div class="peach-health-row">247                      <div class="peach-health-row-info">248                        <span class="peach-health-row-name">249                          Used in {group.entries.length} places250                        </span>251                        <span class="peach-health-row-meta">252                          {group.entries.map((e) => e.title).join(", ")}253                        </span>254                      </div>255                      <button256                        class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"257                        onClick={() => setViewingReusedGroup(group.entries)}258                      >259                        View260                        <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;">261                          <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />262                        </svg>263                      </button>264                    </div>265                  )}266                </For>267              </Show>268            </HealthSection>269270            <HealthSection271              title="Weak"272              count={weakEntries().length}273              expanded={expandedSections().weak}274              onToggle={() => toggleSection("weak")}275              emptyMessage="No weak passwords"276            >277              <For each={weakEntries()}>278                {({ entry, score }) => (279                    <div class="peach-health-row">280                      <div class="peach-health-row-info">281                        <span class="peach-health-row-name">282                          {entry.url ? getDisplayDomain(entry.url) : entry.title}283                        </span>284                        <span class="peach-health-row-meta">285                          score: {score} ({scoreLabel(score)})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="Old"304              count={oldEntries().length}305              expanded={expandedSections().old}306              onToggle={() => toggleSection("old")}307              emptyMessage="No old entries"308            >309              <For each={oldEntries()}>310                {(entry) => {311                  const age = Date.now() - entry.modifiedAt;312                  return (313                    <div class="peach-health-row">314                      <div class="peach-health-row-info">315                        <span class="peach-health-row-name">316                          {entry.url ? getDisplayDomain(entry.url) : entry.title}317                        </span>318                        <span class="peach-health-row-meta">319                          last changed {formatRelativeTime(age)}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                }}334              </For>335            </HealthSection>336337            <HealthSection338              title="Passkeys"339              count={stalePasskeys().length}340              expanded={expandedSections().passkeys}341              onToggle={() => toggleSection("passkeys")}342              emptyMessage="No stale passkeys"343            >344              <For each={stalePasskeys()}>345                {(item) => (346                  <div class="peach-health-row">347                    <div class="peach-health-row-info">348                      <span class="peach-health-row-name">{item.entry.title}</span>349                      <span class="peach-health-row-meta">350                        {item.status} • {item.entry.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}351                      </span>352                    </div>353                    <button354                      class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"355                      onClick={() => props.onFixEntry(item.entry)}356                    >357                      View358                      <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;">359                        <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />360                      </svg>361                    </button>362                  </div>363                )}364              </For>365            </HealthSection>366          </div>367        </div>368      </div>369370      <Show when={viewingReusedGroup()}>371        <VaultView372          entries={viewingReusedGroup()!}373          selectedEntryId={undefined}374          sortOrder="az"375          onSortOrderChange={() => {}}376          onSelectEntry={(entry) => {377            setViewingReusedGroup(null);378            props.onFixEntry(entry);379          }}380          onAdd={() => {}}381          onClose={() => setViewingReusedGroup(null)}382        />383      </Show>384    </>385  );386}387388interface HealthSectionProps {389  title: string;390  count: number;391  expanded: boolean;392  onToggle: () => void;393  loading?: boolean;394  emptyMessage: string;395  children: any;396}397398function HealthSection(props: HealthSectionProps) {399  return (400    <div class="peach-health-section">401      <button class="peach-health-section-header" onClick={props.onToggle}>402        <div class="peach-health-section-title-row">403          <span class="peach-health-count-badge">{props.count}</span>404          <span class="peach-health-section-title">{props.title}</span>405        </div>406        <svg407          class="peach-health-chevron"408          classList={{ "is-expanded": props.expanded }}409          width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"410        >411          <path d="M1 1L5 5L9 1" />412        </svg>413      </button>414      <Show when={props.expanded}>415        <div class="peach-health-section-body">416          <Show417            when={!props.loading && props.count > 0}418            fallback={419              <Show when={!props.loading}>420                <div class="peach-health-empty">{props.emptyMessage}</div>421              </Show>422            }423          >424            {props.children}425          </Show>426        </div>427      </Show>428    </div>429  );430}