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 async function computeReused() {129 const loginEntries = props.entries.filter((e) => e.type !== "note" && e.password);130 if (loginEntries.length === 0) {131 setReusedGroups([]);132 setReusedLoading(false);133 return;134 }135 const hashes = await Promise.all(136 loginEntries.map(async (e) => ({ entry: e, hash: await sha256(e.password!) }))137 );138 const map = new Map<string, VaultEntry[]>();139 for (const { entry, hash } of hashes) {140 const group = map.get(hash) ?? [];141 group.push(entry);142 map.set(hash, group);143 }144 const groups: ReusedGroup[] = [];145 map.forEach((entries, hash) => {146 if (entries.length >= 2) {147 groups.push({ hash, entries });148 }149 });150 groups.sort((a, b) => b.entries.length - a.entries.length);151 setReusedGroups(groups);152 setReusedLoading(false);153 }154155 computeReused();156157 function toggleSection(key: string) {158 setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));159 }160161 async function handleCheckAllBreaches() {162 setCheckingBreaches(true);163 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });164 setCheckingBreaches(false);165 if ((res as { ok: boolean }).ok) {166 props.onRefresh();167 }168 }169170 const totalNeedsAttention = createMemo(171 () =>172 breachedEntries().length +173 reusedGroups().length +174 weakEntries().length + 175 oldEntries().length +176 stalePasskeys().length177 );178179 return (180 <>181 <div class="peach-overlay-view ov-enter-right" style="display:flex">182 <div class="peach-overlay-header">183 <button184 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"185 onClick={props.onBack}186 aria-label="Back"187 >188 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">189 <path d="M6 1L1 6L6 11" />190 </svg>191 </button>192 <span class="peach-overlay-title">vault health</span>193 <div class="peach-overlay-spacer"></div>194 </div>195196 <div class="peach-health-body">197 <Show when={props.isPro && advancedLoading()}>198 <div class="peach-health-loading-screen" aria-busy="true" aria-live="polite">199 <div class="peach-health-loading-glyph" aria-hidden="true">200 <svg width="32" height="32" viewBox="0 0 32 32" fill="none">201 <circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="1.2" opacity="0.18" />202 <path d="M16 3a13 13 0 0 1 13 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">203 <animateTransform attributeName="transform" type="rotate" from="0 16 16" to="360 16 16" dur="1.2s" repeatCount="indefinite" />204 </path>205 </svg>206 </div>207 <h2 class="peach-health-loading-title">Checking your vault's health</h2>208 <p class="peach-health-loading-copy">209 Scanning for reused, breached, weak, and aging credentials.210 Just a moment…211 </p>212 <div class="peach-health-loading-track" aria-hidden="true">213 <div class="peach-health-loading-fill"></div>214 </div>215 </div>216 </Show>217 <Show when={props.isPro && !advancedLoading() && advancedReport() && advancedReport()!.remediation.length > 0}>218 <div class="peach-health-pro-queue">219 <div class="peach-health-pro-queue-head">220 <span class="peach-health-pro-queue-title">Remediation queue</span>221 <span class="peach-health-pro-queue-score">222 Vault score {advancedReport()!.vaultScore}/100223 </span>224 </div>225 <For each={advancedReport()!.remediation.slice(0, 8)}>226 {(item) => (227 <div class="peach-health-pro-row">228 <div class="peach-health-pro-row-info">229 <span class="peach-health-pro-row-name">{item.title}</span>230 <span class="peach-health-pro-row-meta">231 {item.reasons.join(" · ")}232 </span>233 <div class="peach-health-pro-bar" aria-hidden="true">234 <div235 class="peach-health-pro-bar-fill"236 style={{ width: `${Math.min(item.score, 100)}%` }}237 ></div>238 </div>239 </div>240 </div>241 )}242 </For>243 </div>244 </Show>245246 <div class="peach-health-summary">247 <Show248 when={totalNeedsAttention() > 0}249 fallback={<span class="peach-health-summary-ok">Your vault looks healthy.</span>}250 >251 <span class="peach-health-summary-warn">252 {totalNeedsAttention()} {totalNeedsAttention() === 1 ? "entry needs" : "entries need"} attention253 </span>254 </Show>255 </div>256257 <div class="peach-health-check-row">258 <button259 class="peach-btn peach-btn--accent peach-btn--sm peach-health-check-btn"260 onClick={() => void handleCheckAllBreaches()}261 disabled={checkingBreaches()}262 >263 <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;">264 <path d="M9 6L5 10L1 6" />265 <path d="M5 10V2" />266 </svg>267 {checkingBreaches() ? "checking..." : "Check all breaches now"}268 </button>269 </div>270271 <div class="peach-health-sections">272 <HealthSection273 title="Breached"274 count={breachedEntries().length}275 expanded={expandedSections().breached}276 onToggle={() => toggleSection("breached")}277 emptyMessage="No breached entries"278 >279 <For each={breachedEntries()}>280 {(entry) => (281 <div class="peach-health-row">282 <div class="peach-health-row-info">283 <span class="peach-health-row-name">{entry.title}</span>284 <span class="peach-health-row-meta" style="color:var(--peach-breach);">285 {formatExposure(entry.breach ?? 0)}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="Reused"304 count={reusedGroups().length}305 expanded={expandedSections().reused}306 onToggle={() => toggleSection("reused")}307 loading={reusedLoading()}308 emptyMessage="No reused passwords"309 >310 <Show when={!reusedLoading()} fallback={<div class="peach-health-loading">Computing...</div>}>311 <For each={reusedGroups()}>312 {(group) => (313 <div class="peach-health-row">314 <div class="peach-health-row-info">315 <span class="peach-health-row-name">316 Used in {group.entries.length} places317 </span>318 <span class="peach-health-row-meta">319 {group.entries.map((e) => e.title).join(", ")}320 </span>321 </div>322 <button323 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"324 onClick={() => setViewingReusedGroup(group.entries)}325 >326 View327 <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 </Show>335 </HealthSection>336337 <HealthSection338 title="Weak"339 count={weakEntries().length}340 expanded={expandedSections().weak}341 onToggle={() => toggleSection("weak")}342 emptyMessage="No weak passwords"343 >344 <For each={weakEntries()}>345 {({ entry, score }) => (346 <div class="peach-health-row">347 <div class="peach-health-row-info">348 <span class="peach-health-row-name">349 {entry.url ? getDisplayDomain(entry.url) : entry.title}350 </span>351 <span class="peach-health-row-meta">352 score: {score} ({scoreLabel(score)})353 </span>354 </div>355 <button356 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"357 onClick={() => props.onFixEntry(entry)}358 >359 Fix360 <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;">361 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />362 </svg>363 </button>364 </div>365 )}366 </For>367 </HealthSection>368369 <HealthSection370 title="Old"371 count={oldEntries().length}372 expanded={expandedSections().old}373 onToggle={() => toggleSection("old")}374 emptyMessage="No old entries"375 >376 <For each={oldEntries()}>377 {(entry) => {378 const age = Date.now() - entry.modifiedAt;379 return (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 last changed {formatRelativeTime(age)}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 }}401 </For>402 </HealthSection>403404 <HealthSection405 title="Passkeys"406 count={stalePasskeys().length}407 expanded={expandedSections().passkeys}408 onToggle={() => toggleSection("passkeys")}409 emptyMessage="No stale passkeys"410 >411 <For each={stalePasskeys()}>412 {(item) => (413 <div class="peach-health-row">414 <div class="peach-health-row-info">415 <span class="peach-health-row-name">{item.entry.title}</span>416 <span class="peach-health-row-meta">417 {item.status} • {item.entry.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}418 </span>419 </div>420 <button421 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"422 onClick={() => props.onFixEntry(item.entry)}423 >424 View425 <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;">426 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />427 </svg>428 </button>429 </div>430 )}431 </For>432 </HealthSection>433 </div>434 </div>435 </div>436437 <Show when={viewingReusedGroup()}>438 <VaultView439 entries={viewingReusedGroup()!}440 selectedEntryId={undefined}441 sortOrder="az"442 onSortOrderChange={() => {}}443 onSelectEntry={(entry) => {444 setViewingReusedGroup(null);445 props.onFixEntry(entry);446 }}447 onAdd={() => {}}448 onClose={() => setViewingReusedGroup(null)}449 />450 </Show>451 </>452 );453}454455interface HealthSectionProps {456 title: string;457 count: number;458 expanded: boolean;459 onToggle: () => void;460 loading?: boolean;461 emptyMessage: string;462 children: any;463}464465function HealthSection(props: HealthSectionProps) {466 return (467 <div class="peach-health-section">468 <button class="peach-health-section-header" onClick={props.onToggle}>469 <div class="peach-health-section-title-row">470 <span class="peach-health-count-badge">{props.count}</span>471 <span class="peach-health-section-title">{props.title}</span>472 </div>473 <svg474 class="peach-health-chevron"475 classList={{ "is-expanded": props.expanded }}476 width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"477 >478 <path d="M1 1L5 5L9 1" />479 </svg>480 </button>481 <Show when={props.expanded}>482 <div class="peach-health-section-body">483 <Show484 when={!props.loading && props.count > 0}485 fallback={486 <Show when={!props.loading}>487 <div class="peach-health-empty">{props.emptyMessage}</div>488 </Show>489 }490 >491 {props.children}492 </Show>493 </div>494 </Show>495 </div>496 );497}