1import { createMemo, createSignal, For, onMount, Show } from "solid-js";2import { zxcvbn, zxcvbnOptions } from "@zxcvbn-ts/core";3import * as zxcvbnCommon from "@zxcvbn-ts/language-common";4import * as zxcvbnEn from "@zxcvbn-ts/language-en";5import type { AdvancedHealthReport, VaultEntry } from "../../shared/types";6import { getDisplayDomain } from "../../shared/domain";7import { sendMessage } from "../App";8import { VaultView } from "./Vault";9101112131415zxcvbnOptions.setOptions({16 translations: zxcvbnEn.translations,17 graphs: zxcvbnCommon.adjacencyGraphs,18 dictionary: { ...zxcvbnCommon.dictionary, ...zxcvbnEn.dictionary },19});2021function sha256(plain: string): Promise<string> {22 const encoder = new TextEncoder();23 const data = encoder.encode(plain);24 return crypto.subtle.digest("SHA-256", data).then((buf) => {25 return Array.from(new Uint8Array(buf))26 .map((b) => b.toString(16).padStart(2, "0"))27 .join("");28 });29}3031interface ReusedGroup {32 hash: string;33 entries: VaultEntry[];34}3536interface HealthProps {37 entries: VaultEntry[];38 isPro?: boolean;39 onFixEntry: (entry: VaultEntry) => void;40 onBack: () => void;41 onRefresh: () => void;42 4344 hidden?: boolean;45}4647function formatExposure(count: number): string {48 if (count >= 1000) return `${(count / 1000).toFixed(count % 1000 === 0 ? 0 : 1)}k exposures`;49 return `${count.toLocaleString()} ${count === 1 ? "exposure" : "exposures"}`;50}5152function formatRelativeTime(ms: number): string {53 const seconds = Math.floor(ms / 1000);54 if (seconds < 60) return "just now";55 const minutes = Math.floor(seconds / 60);56 if (minutes < 60) return `${minutes} minute${minutes !== 1 ? "s" : ""} ago`;57 const hours = Math.floor(minutes / 60);58 if (hours < 24) return `${hours} hour${hours !== 1 ? "s" : ""} ago`;59 const days = Math.floor(hours / 24);60 if (days < 30) return `${days} day${days !== 1 ? "s" : ""} ago`;61 const months = Math.floor(days / 30);62 if (months < 12) return `${months} month${months !== 1 ? "s" : ""} ago`;63 const years = Math.floor(months / 12);64 return `${years} year${years !== 1 ? "s" : ""} ago`;65}6667function scoreLabel(score: number): string {68 const labels = ["very weak", "weak", "fair", "good", "strong"];69 return labels[score] ?? "unknown";70}7172export function HealthView(props: HealthProps) {73 const [checkingBreaches, setCheckingBreaches] = createSignal(false);74 const [reusedGroups, setReusedGroups] = createSignal<ReusedGroup[]>([]);75 const [reusedLoading, setReusedLoading] = createSignal(true);76 const [expandedSections, setExpandedSections] = createSignal<Record<string, boolean>>({77 breached: true,78 reused: true,79 weak: true,80 old: true,81 passkeys: true,82 });83 const [viewingReusedGroup, setViewingReusedGroup] = createSignal<VaultEntry[] | null>(null);84 const [advancedReport, setAdvancedReport] = createSignal<AdvancedHealthReport | null>(null);85 86 87 const [advancedLoading, setAdvancedLoading] = createSignal<boolean>(!!props.isPro);8889 onMount(async () => {90 if (!props.isPro) return;91 try {92 const res = await sendMessage({ type: "GET_ADVANCED_HEALTH_REPORT" });93 if ((res as { ok: boolean }).ok) {94 setAdvancedReport((res as { ok: boolean; data?: AdvancedHealthReport }).data ?? null);95 }96 } finally {97 setAdvancedLoading(false);98 }99 });100101 const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;102103 const breachedEntries = createMemo(() =>104 props.entries105 .filter((e) => (e.breach ?? 0) > 0)106 .sort((a, b) => (b.breach ?? 0) - (a.breach ?? 0))107 );108109 const weakEntries = createMemo(() => {110 111 const scored = props.entries112 .filter((e) => e.type !== "note" && e.password)113 .map((entry) => ({ entry, score: zxcvbn(entry.password!).score }))114 .filter(({ score }) => score <= 1);115 scored.sort((a, b) => a.score - b.score);116 return scored;117 });118119 const oldEntries = createMemo(() =>120 props.entries121 .filter((e) => e.type === "login" && e.modifiedAt < Date.now() - ONE_YEAR_MS)122 .sort((a, b) => a.modifiedAt - b.modifiedAt)123 );124125 const stalePasskeys = createMemo(() =>126 props.entries127 .filter((e) => e.passkey !== undefined)128 .map((entry) => {129 const passkey = entry.passkey!;130 const lastActivity = passkey.lastUsedAt ?? passkey.createdAt ?? entry.createdAt;131 const ageMs = Date.now() - lastActivity;132 const status =133 passkey.signCount === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000134 ? "Unused"135 : ageMs >= ONE_YEAR_MS136 ? "Old"137 : null;138 return { entry, lastActivity, status };139 })140 .filter((item) => item.status !== null)141 .sort((a, b) => a.lastActivity - b.lastActivity)142 );143144 const entriesById = createMemo(() => {145 const map = new Map<string, VaultEntry>();146 for (const entry of props.entries) map.set(entry.id, entry);147 return map;148 });149150 async function computeReused() {151 const loginEntries = props.entries.filter((e) => e.type !== "note" && e.password);152 if (loginEntries.length === 0) {153 setReusedGroups([]);154 setReusedLoading(false);155 return;156 }157 const hashes = await Promise.all(158 loginEntries.map(async (e) => ({ entry: e, hash: await sha256(e.password!) }))159 );160 const map = new Map<string, VaultEntry[]>();161 for (const { entry, hash } of hashes) {162 const group = map.get(hash) ?? [];163 group.push(entry);164 map.set(hash, group);165 }166 const groups: ReusedGroup[] = [];167 map.forEach((entries, hash) => {168 if (entries.length >= 2) {169 groups.push({ hash, entries });170 }171 });172 groups.sort((a, b) => b.entries.length - a.entries.length);173 setReusedGroups(groups);174 setReusedLoading(false);175 }176177 computeReused();178179 function toggleSection(key: string) {180 setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));181 }182183 async function handleCheckAllBreaches() {184 setCheckingBreaches(true);185 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });186 setCheckingBreaches(false);187 if ((res as { ok: boolean }).ok) {188 props.onRefresh();189 }190 }191192 const totalNeedsAttention = createMemo(193 () =>194 breachedEntries().length +195 reusedGroups().length +196 weakEntries().length + 197 oldEntries().length +198 stalePasskeys().length199 );200201 const isLoading = () => reusedLoading() || (!!props.isPro && advancedLoading());202203 return (204 <>205 <div class="peach-overlay-view ov-enter-right" style={{ display: props.hidden ? "none" : "flex" }}>206 <div class="peach-overlay-header">207 <button208 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"209 onClick={props.onBack}210 aria-label="Back"211 >212 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">213 <path d="M6 1L1 6L6 11" />214 </svg>215 </button>216 <span class="peach-overlay-title">Advanced Vault Health</span>217 <div class="peach-overlay-spacer"></div>218 </div>219220 <div class="peach-health-body">221 <Show when={isLoading()}>222 <div class="peach-health-loading-screen" aria-busy="true" aria-live="polite">223 <div class="peach-health-loading-glyph" aria-hidden="true">224 <svg width="32" height="32" viewBox="0 0 32 32" fill="none">225 <circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="1.2" opacity="0.18" />226 <path d="M16 3a13 13 0 0 1 13 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">227 <animateTransform attributeName="transform" type="rotate" from="0 16 16" to="360 16 16" dur="1.2s" repeatCount="indefinite" />228 </path>229 </svg>230 </div>231 <h2 class="peach-health-loading-title">Checking your vault's health</h2>232 <p class="peach-health-loading-copy">233 Scanning for reused, breached, weak, and aging credentials.234 Just a moment…235 </p>236 <div class="peach-health-loading-track" aria-hidden="true">237 <div class="peach-health-loading-fill"></div>238 </div>239 </div>240 </Show>241 <Show when={!isLoading()}>242 <Show when={props.isPro && advancedReport() && advancedReport()!.remediation.length > 0}>243 <div class="peach-health-pro-queue">244 <div class="peach-health-pro-queue-head">245 <span class="peach-health-pro-queue-title">Remediation queue</span>246 <span class="peach-health-pro-queue-score">247 Vault score {advancedReport()!.vaultScore}/100248 </span>249 </div>250 <For each={advancedReport()!.remediation.slice(0, 8)}>251 {(item) => {252 const entry = () => entriesById().get(item.id);253 return (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 <Show when={entry()}>268 {(target) => (269 <button270 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-pro-action"271 onClick={() => props.onFixEntry(target())}272 aria-label={`Update password for ${item.title}`}273 >274 Update275 </button>276 )}277 </Show>278 </div>279 );280 }}281 </For>282 </div>283 </Show>284285 <div class="peach-health-summary">286 <Show287 when={totalNeedsAttention() > 0}288 fallback={<span class="peach-health-summary-ok">Your vault looks healthy.</span>}289 >290 <span class="peach-health-summary-warn">291 {totalNeedsAttention()} {totalNeedsAttention() === 1 ? "entry needs" : "entries need"} attention292 </span>293 </Show>294 </div>295296 <div class="peach-health-check-row">297 <button298 class="peach-btn peach-btn--accent peach-btn--sm peach-health-check-btn"299 onClick={() => void handleCheckAllBreaches()}300 disabled={checkingBreaches()}301 >302 <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;">303 <path d="M9 6L5 10L1 6" />304 <path d="M5 10V2" />305 </svg>306 {checkingBreaches() ? "checking..." : "Check all breaches now"}307 </button>308 </div>309310 <div class="peach-health-sections">311 <HealthSection312 title="Breached"313 count={breachedEntries().length}314 expanded={expandedSections().breached}315 onToggle={() => toggleSection("breached")}316 emptyMessage="No breached entries"317 >318 <For each={breachedEntries()}>319 {(entry) => (320 <div class="peach-health-row">321 <div class="peach-health-row-info">322 <span class="peach-health-row-name">{entry.title}</span>323 <span class="peach-health-row-meta" style="color:var(--peach-breach);">324 {formatExposure(entry.breach ?? 0)}325 </span>326 </div>327 <button328 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"329 onClick={() => props.onFixEntry(entry)}330 >331 Fix332 <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;">333 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />334 </svg>335 </button>336 </div>337 )}338 </For>339 </HealthSection>340341 <HealthSection342 title="Reused"343 count={reusedGroups().length}344 expanded={expandedSections().reused}345 onToggle={() => toggleSection("reused")}346 loading={reusedLoading()}347 emptyMessage="No reused passwords"348 >349 <Show when={!reusedLoading()} fallback={<div class="peach-health-loading">Computing...</div>}>350 <For each={reusedGroups()}>351 {(group) => (352 <div class="peach-health-row">353 <div class="peach-health-row-info">354 <span class="peach-health-row-name">355 Used in {group.entries.length} places356 </span>357 <span class="peach-health-row-meta">358 {group.entries.map((e) => e.title).join(", ")}359 </span>360 </div>361 <button362 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"363 onClick={() => setViewingReusedGroup(group.entries)}364 >365 View366 <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;">367 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />368 </svg>369 </button>370 </div>371 )}372 </For>373 </Show>374 </HealthSection>375376 <HealthSection377 title="Weak"378 count={weakEntries().length}379 expanded={expandedSections().weak}380 onToggle={() => toggleSection("weak")}381 emptyMessage="No weak passwords"382 >383 <For each={weakEntries()}>384 {({ entry, score }) => (385 <div class="peach-health-row">386 <div class="peach-health-row-info">387 <span class="peach-health-row-name">388 {entry.url ? getDisplayDomain(entry.url) : entry.title}389 </span>390 <span class="peach-health-row-meta">391 score: {score} ({scoreLabel(score)})392 </span>393 </div>394 <button395 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"396 onClick={() => props.onFixEntry(entry)}397 >398 Fix399 <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;">400 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />401 </svg>402 </button>403 </div>404 )}405 </For>406 </HealthSection>407408 <HealthSection409 title="Old"410 count={oldEntries().length}411 expanded={expandedSections().old}412 onToggle={() => toggleSection("old")}413 emptyMessage="No old entries"414 >415 <For each={oldEntries()}>416 {(entry) => {417 const age = Date.now() - entry.modifiedAt;418 return (419 <div class="peach-health-row">420 <div class="peach-health-row-info">421 <span class="peach-health-row-name">422 {entry.url ? getDisplayDomain(entry.url) : entry.title}423 </span>424 <span class="peach-health-row-meta">425 last changed {formatRelativeTime(age)}426 </span>427 </div>428 <button429 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"430 onClick={() => props.onFixEntry(entry)}431 >432 Fix433 <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;">434 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />435 </svg>436 </button>437 </div>438 );439 }}440 </For>441 </HealthSection>442443 <HealthSection444 title="Passkeys"445 count={stalePasskeys().length}446 expanded={expandedSections().passkeys}447 onToggle={() => toggleSection("passkeys")}448 emptyMessage="No stale passkeys"449 >450 <For each={stalePasskeys()}>451 {(item) => (452 <div class="peach-health-row">453 <div class="peach-health-row-info">454 <span class="peach-health-row-name">{item.entry.title}</span>455 <span class="peach-health-row-meta">456 {item.status} • {item.entry.passkey?.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}457 </span>458 </div>459 <button460 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"461 onClick={() => props.onFixEntry(item.entry)}462 >463 View464 <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;">465 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />466 </svg>467 </button>468 </div>469 )}470 </For>471 </HealthSection>472 </div>473 </Show>{}474 </div>475 </div>476477 <Show when={viewingReusedGroup()}>478 <VaultView479 entries={viewingReusedGroup()!}480 selectedEntryId={undefined}481 sortOrder="az"482 onSortOrderChange={() => {}}483 onSelectEntry={(entry) => {484 setViewingReusedGroup(null);485 props.onFixEntry(entry);486 }}487 onAdd={() => {}}488 onClose={() => setViewingReusedGroup(null)}489 />490 </Show>491 </>492 );493}494495interface HealthSectionProps {496 title: string;497 count: number;498 expanded: boolean;499 onToggle: () => void;500 loading?: boolean;501 emptyMessage: string;502 children: any;503}504505function HealthSection(props: HealthSectionProps) {506 return (507 <div class="peach-health-section">508 <button class="peach-health-section-header" onClick={props.onToggle}>509 <div class="peach-health-section-title-row">510 <span class="peach-health-count-badge">{props.count}</span>511 <span class="peach-health-section-title">{props.title}</span>512 </div>513 <svg514 class="peach-health-chevron"515 classList={{ "is-expanded": props.expanded }}516 width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"517 >518 <path d="M1 1L5 5L9 1" />519 </svg>520 </button>521 <Show when={props.expanded}>522 <div class="peach-health-section-body">523 <Show524 when={!props.loading && props.count > 0}525 fallback={526 <Show when={!props.loading}>527 <div class="peach-health-empty">{props.emptyMessage}</div>528 </Show>529 }530 >531 {props.children}532 </Show>533 </div>534 </Show>535 </div>536 );537}