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 70 71 const [advancedLoading, setAdvancedLoading] = createSignal<boolean>(!!props.isPro);7273 onMount(async () => {74 if (!props.isPro) return;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 const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;8687 const breachedEntries = createMemo(() =>88 props.entries89 .filter((e) => (e.breach ?? 0) > 0)90 .sort((a, b) => (b.breach ?? 0) - (a.breach ?? 0))91 );9293 const weakEntries = createMemo(() => {94 95 const scored = props.entries96 .filter((e) => e.type !== "note" && e.password)97 .map((entry) => ({ entry, score: zxcvbn(entry.password!).score }))98 .filter(({ score }) => score <= 1);99 scored.sort((a, b) => a.score - b.score);100 return scored;101 });102103 const oldEntries = createMemo(() =>104 props.entries105 .filter((e) => e.type === "login" && e.modifiedAt < Date.now() - ONE_YEAR_MS)106 .sort((a, b) => a.modifiedAt - b.modifiedAt)107 );108109 const stalePasskeys = createMemo(() =>110 props.entries111 .filter((e) => e.passkey !== undefined)112 .map((entry) => {113 const passkey = entry.passkey!;114 const lastActivity = passkey.lastUsedAt ?? passkey.createdAt ?? entry.createdAt;115 const ageMs = Date.now() - lastActivity;116 const status =117 passkey.signCount === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000118 ? "Unused"119 : ageMs >= ONE_YEAR_MS120 ? "Old"121 : null;122 return { entry, lastActivity, status };123 })124 .filter((item) => item.status !== null)125 .sort((a, b) => a.lastActivity - b.lastActivity)126 );127128 const entriesById = createMemo(() => {129 const map = new Map<string, VaultEntry>();130 for (const entry of props.entries) map.set(entry.id, entry);131 return map;132 });133134 async function computeReused() {135 const loginEntries = props.entries.filter((e) => e.type !== "note" && e.password);136 if (loginEntries.length === 0) {137 setReusedGroups([]);138 setReusedLoading(false);139 return;140 }141 const hashes = await Promise.all(142 loginEntries.map(async (e) => ({ entry: e, hash: await sha256(e.password!) }))143 );144 const map = new Map<string, VaultEntry[]>();145 for (const { entry, hash } of hashes) {146 const group = map.get(hash) ?? [];147 group.push(entry);148 map.set(hash, group);149 }150 const groups: ReusedGroup[] = [];151 map.forEach((entries, hash) => {152 if (entries.length >= 2) {153 groups.push({ hash, entries });154 }155 });156 groups.sort((a, b) => b.entries.length - a.entries.length);157 setReusedGroups(groups);158 setReusedLoading(false);159 }160161 computeReused();162163 function toggleSection(key: string) {164 setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));165 }166167 async function handleCheckAllBreaches() {168 setCheckingBreaches(true);169 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });170 setCheckingBreaches(false);171 if ((res as { ok: boolean }).ok) {172 props.onRefresh();173 }174 }175176 const totalNeedsAttention = createMemo(177 () =>178 breachedEntries().length +179 reusedGroups().length +180 weakEntries().length + 181 oldEntries().length +182 stalePasskeys().length183 );184185 return (186 <>187 <div class="peach-overlay-view ov-enter-right" style="display:flex">188 <div class="peach-overlay-header">189 <button190 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"191 onClick={props.onBack}192 aria-label="Back"193 >194 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">195 <path d="M6 1L1 6L6 11" />196 </svg>197 </button>198 <span class="peach-overlay-title">vault health</span>199 <div class="peach-overlay-spacer"></div>200 </div>201202 <div class="peach-health-body">203 <Show when={props.isPro && advancedLoading()}>204 <div class="peach-health-loading-screen" aria-busy="true" aria-live="polite">205 <div class="peach-health-loading-glyph" aria-hidden="true">206 <svg width="32" height="32" viewBox="0 0 32 32" fill="none">207 <circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="1.2" opacity="0.18" />208 <path d="M16 3a13 13 0 0 1 13 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">209 <animateTransform attributeName="transform" type="rotate" from="0 16 16" to="360 16 16" dur="1.2s" repeatCount="indefinite" />210 </path>211 </svg>212 </div>213 <h2 class="peach-health-loading-title">Checking your vault's health</h2>214 <p class="peach-health-loading-copy">215 Scanning for reused, breached, weak, and aging credentials.216 Just a moment…217 </p>218 <div class="peach-health-loading-track" aria-hidden="true">219 <div class="peach-health-loading-fill"></div>220 </div>221 </div>222 </Show>223 <Show when={props.isPro && !advancedLoading() && advancedReport() && advancedReport()!.remediation.length > 0}>224 <div class="peach-health-pro-queue">225 <div class="peach-health-pro-queue-head">226 <span class="peach-health-pro-queue-title">Remediation queue</span>227 <span class="peach-health-pro-queue-score">228 Vault score {advancedReport()!.vaultScore}/100229 </span>230 </div>231 <For each={advancedReport()!.remediation.slice(0, 8)}>232 {(item) => {233 const entry = () => entriesById().get(item.id);234 return (235 <div class="peach-health-pro-row">236 <div class="peach-health-pro-row-info">237 <span class="peach-health-pro-row-name">{item.title}</span>238 <span class="peach-health-pro-row-meta">239 {item.reasons.join(" · ")}240 </span>241 <div class="peach-health-pro-bar" aria-hidden="true">242 <div243 class="peach-health-pro-bar-fill"244 style={{ width: `${Math.min(item.score, 100)}%` }}245 ></div>246 </div>247 </div>248 <Show when={entry()}>249 {(target) => (250 <button251 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-pro-action"252 onClick={() => props.onFixEntry(target())}253 aria-label={`Update password for ${item.title}`}254 >255 Update256 </button>257 )}258 </Show>259 </div>260 );261 }}262 </For>263 </div>264 </Show>265266 <div class="peach-health-summary">267 <Show268 when={totalNeedsAttention() > 0}269 fallback={<span class="peach-health-summary-ok">Your vault looks healthy.</span>}270 >271 <span class="peach-health-summary-warn">272 {totalNeedsAttention()} {totalNeedsAttention() === 1 ? "entry needs" : "entries need"} attention273 </span>274 </Show>275 </div>276277 <div class="peach-health-check-row">278 <button279 class="peach-btn peach-btn--accent peach-btn--sm peach-health-check-btn"280 onClick={() => void handleCheckAllBreaches()}281 disabled={checkingBreaches()}282 >283 <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;">284 <path d="M9 6L5 10L1 6" />285 <path d="M5 10V2" />286 </svg>287 {checkingBreaches() ? "checking..." : "Check all breaches now"}288 </button>289 </div>290291 <div class="peach-health-sections">292 <HealthSection293 title="Breached"294 count={breachedEntries().length}295 expanded={expandedSections().breached}296 onToggle={() => toggleSection("breached")}297 emptyMessage="No breached entries"298 >299 <For each={breachedEntries()}>300 {(entry) => (301 <div class="peach-health-row">302 <div class="peach-health-row-info">303 <span class="peach-health-row-name">{entry.title}</span>304 <span class="peach-health-row-meta" style="color:var(--peach-breach);">305 {formatExposure(entry.breach ?? 0)}306 </span>307 </div>308 <button309 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"310 onClick={() => props.onFixEntry(entry)}311 >312 Fix313 <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;">314 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />315 </svg>316 </button>317 </div>318 )}319 </For>320 </HealthSection>321322 <HealthSection323 title="Reused"324 count={reusedGroups().length}325 expanded={expandedSections().reused}326 onToggle={() => toggleSection("reused")}327 loading={reusedLoading()}328 emptyMessage="No reused passwords"329 >330 <Show when={!reusedLoading()} fallback={<div class="peach-health-loading">Computing...</div>}>331 <For each={reusedGroups()}>332 {(group) => (333 <div class="peach-health-row">334 <div class="peach-health-row-info">335 <span class="peach-health-row-name">336 Used in {group.entries.length} places337 </span>338 <span class="peach-health-row-meta">339 {group.entries.map((e) => e.title).join(", ")}340 </span>341 </div>342 <button343 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"344 onClick={() => setViewingReusedGroup(group.entries)}345 >346 View347 <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;">348 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />349 </svg>350 </button>351 </div>352 )}353 </For>354 </Show>355 </HealthSection>356357 <HealthSection358 title="Weak"359 count={weakEntries().length}360 expanded={expandedSections().weak}361 onToggle={() => toggleSection("weak")}362 emptyMessage="No weak passwords"363 >364 <For each={weakEntries()}>365 {({ entry, score }) => (366 <div class="peach-health-row">367 <div class="peach-health-row-info">368 <span class="peach-health-row-name">369 {entry.url ? getDisplayDomain(entry.url) : entry.title}370 </span>371 <span class="peach-health-row-meta">372 score: {score} ({scoreLabel(score)})373 </span>374 </div>375 <button376 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"377 onClick={() => props.onFixEntry(entry)}378 >379 Fix380 <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;">381 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />382 </svg>383 </button>384 </div>385 )}386 </For>387 </HealthSection>388389 <HealthSection390 title="Old"391 count={oldEntries().length}392 expanded={expandedSections().old}393 onToggle={() => toggleSection("old")}394 emptyMessage="No old entries"395 >396 <For each={oldEntries()}>397 {(entry) => {398 const age = Date.now() - entry.modifiedAt;399 return (400 <div class="peach-health-row">401 <div class="peach-health-row-info">402 <span class="peach-health-row-name">403 {entry.url ? getDisplayDomain(entry.url) : entry.title}404 </span>405 <span class="peach-health-row-meta">406 last changed {formatRelativeTime(age)}407 </span>408 </div>409 <button410 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"411 onClick={() => props.onFixEntry(entry)}412 >413 Fix414 <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;">415 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />416 </svg>417 </button>418 </div>419 );420 }}421 </For>422 </HealthSection>423424 <HealthSection425 title="Passkeys"426 count={stalePasskeys().length}427 expanded={expandedSections().passkeys}428 onToggle={() => toggleSection("passkeys")}429 emptyMessage="No stale passkeys"430 >431 <For each={stalePasskeys()}>432 {(item) => (433 <div class="peach-health-row">434 <div class="peach-health-row-info">435 <span class="peach-health-row-name">{item.entry.title}</span>436 <span class="peach-health-row-meta">437 {item.status} • {item.entry.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}438 </span>439 </div>440 <button441 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"442 onClick={() => props.onFixEntry(item.entry)}443 >444 View445 <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;">446 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />447 </svg>448 </button>449 </div>450 )}451 </For>452 </HealthSection>453 </div>454 </div>455 </div>456457 <Show when={viewingReusedGroup()}>458 <VaultView459 entries={viewingReusedGroup()!}460 selectedEntryId={undefined}461 sortOrder="az"462 onSortOrderChange={() => {}}463 onSelectEntry={(entry) => {464 setViewingReusedGroup(null);465 props.onFixEntry(entry);466 }}467 onAdd={() => {}}468 onClose={() => setViewingReusedGroup(null)}469 />470 </Show>471 </>472 );473}474475interface HealthSectionProps {476 title: string;477 count: number;478 expanded: boolean;479 onToggle: () => void;480 loading?: boolean;481 emptyMessage: string;482 children: any;483}484485function HealthSection(props: HealthSectionProps) {486 return (487 <div class="peach-health-section">488 <button class="peach-health-section-header" onClick={props.onToggle}>489 <div class="peach-health-section-title-row">490 <span class="peach-health-count-badge">{props.count}</span>491 <span class="peach-health-section-title">{props.title}</span>492 </div>493 <svg494 class="peach-health-chevron"495 classList={{ "is-expanded": props.expanded }}496 width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"497 >498 <path d="M1 1L5 5L9 1" />499 </svg>500 </button>501 <Show when={props.expanded}>502 <div class="peach-health-section-body">503 <Show504 when={!props.loading && props.count > 0}505 fallback={506 <Show when={!props.loading}>507 <div class="peach-health-empty">{props.emptyMessage}</div>508 </Show>509 }510 >511 {props.children}512 </Show>513 </div>514 </Show>515 </div>516 );517}