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 const [smartChangingId, setSmartChangingId] = createSignal<string | null>(null);7071 onMount(async () => {72 if (!props.isPro) return;73 const res = await sendMessage({ type: "GET_ADVANCED_HEALTH_REPORT" });74 if ((res as { ok: boolean }).ok) {75 setAdvancedReport((res as { ok: boolean; data?: AdvancedHealthReport }).data ?? null);76 }77 });7879 async function handleSmartChange(entryId: string) {80 setSmartChangingId(entryId);81 const res = await sendMessage({82 type: "INITIATE_PASSWORD_CHANGE",83 payload: { id: entryId },84 });85 setSmartChangingId(null);86 if ((res as { ok: boolean }).ok) {87 88 89 90 window.close();91 }92 }9394 const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;9596 const breachedEntries = createMemo(() =>97 props.entries98 .filter((e) => (e.breach ?? 0) > 0)99 .sort((a, b) => (b.breach ?? 0) - (a.breach ?? 0))100 );101102 const weakEntries = createMemo(() => {103 104 const scored = props.entries105 .filter((e) => e.type !== "note" && e.password)106 .map((entry) => ({ entry, score: zxcvbn(entry.password!).score }))107 .filter(({ score }) => score <= 1);108 scored.sort((a, b) => a.score - b.score);109 return scored;110 });111112 const oldEntries = createMemo(() =>113 props.entries114 .filter((e) => e.type === "login" && e.modifiedAt < Date.now() - ONE_YEAR_MS)115 .sort((a, b) => a.modifiedAt - b.modifiedAt)116 );117118 const stalePasskeys = createMemo(() =>119 props.entries120 .filter((e) => e.passkey !== undefined)121 .map((entry) => {122 const passkey = entry.passkey!;123 const lastActivity = passkey.lastUsedAt ?? passkey.createdAt ?? entry.createdAt;124 const ageMs = Date.now() - lastActivity;125 const status =126 passkey.signCount === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000127 ? "Unused"128 : ageMs >= ONE_YEAR_MS129 ? "Old"130 : null;131 return { entry, lastActivity, status };132 })133 .filter((item) => item.status !== null)134 .sort((a, b) => a.lastActivity - b.lastActivity)135 );136137 async function computeReused() {138 const loginEntries = props.entries.filter((e) => e.type !== "note" && e.password);139 if (loginEntries.length === 0) {140 setReusedGroups([]);141 setReusedLoading(false);142 return;143 }144 const hashes = await Promise.all(145 loginEntries.map(async (e) => ({ entry: e, hash: await sha256(e.password!) }))146 );147 const map = new Map<string, VaultEntry[]>();148 for (const { entry, hash } of hashes) {149 const group = map.get(hash) ?? [];150 group.push(entry);151 map.set(hash, group);152 }153 const groups: ReusedGroup[] = [];154 map.forEach((entries, hash) => {155 if (entries.length >= 2) {156 groups.push({ hash, entries });157 }158 });159 groups.sort((a, b) => b.entries.length - a.entries.length);160 setReusedGroups(groups);161 setReusedLoading(false);162 }163164 computeReused();165166 function toggleSection(key: string) {167 setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));168 }169170 async function handleCheckAllBreaches() {171 setCheckingBreaches(true);172 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });173 setCheckingBreaches(false);174 if ((res as { ok: boolean }).ok) {175 props.onRefresh();176 }177 }178179 const totalNeedsAttention = createMemo(180 () =>181 breachedEntries().length +182 reusedGroups().length +183 weakEntries().length + 184 oldEntries().length +185 stalePasskeys().length186 );187188 return (189 <>190 <div class="peach-overlay-view ov-enter-right" style="display:flex">191 <div class="peach-overlay-header">192 <button193 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"194 onClick={props.onBack}195 aria-label="Back"196 >197 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">198 <path d="M6 1L1 6L6 11" />199 </svg>200 </button>201 <span class="peach-overlay-title">vault health</span>202 <div class="peach-overlay-spacer"></div>203 </div>204205 <div class="peach-health-body">206 <Show when={props.isPro && advancedReport() && advancedReport()!.remediation.length > 0}>207 <div class="peach-health-pro-queue">208 <div class="peach-health-pro-queue-head">209 <span class="peach-health-pro-queue-title">Remediation queue</span>210 <span class="peach-health-pro-queue-score">211 Vault score {advancedReport()!.vaultScore}/100212 </span>213 </div>214 <For each={advancedReport()!.remediation.slice(0, 8)}>215 {(item) => (216 <div class="peach-health-pro-row">217 <div class="peach-health-pro-row-info">218 <span class="peach-health-pro-row-name">{item.title}</span>219 <span class="peach-health-pro-row-meta">220 {item.reasons.join(" · ")}221 </span>222 <div class="peach-health-pro-bar" aria-hidden="true">223 <div224 class="peach-health-pro-bar-fill"225 style={{ width: `${Math.min(item.score, 100)}%` }}226 ></div>227 </div>228 </div>229 <button230 class="peach-btn peach-btn--accent peach-btn--sm"231 onClick={() => void handleSmartChange(item.id)}232 disabled={smartChangingId() === item.id}233 >234 {smartChangingId() === item.id ? "opening…" : "Change this password"}235 </button>236 </div>237 )}238 </For>239 </div>240 </Show>241242 <div class="peach-health-summary">243 <Show244 when={totalNeedsAttention() > 0}245 fallback={<span class="peach-health-summary-ok">Your vault looks healthy.</span>}246 >247 <span class="peach-health-summary-warn">248 {totalNeedsAttention()} {totalNeedsAttention() === 1 ? "entry needs" : "entries need"} attention249 </span>250 </Show>251 </div>252253 <div class="peach-health-check-row">254 <button255 class="peach-btn peach-btn--accent peach-btn--sm peach-health-check-btn"256 onClick={() => void handleCheckAllBreaches()}257 disabled={checkingBreaches()}258 >259 <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;">260 <path d="M9 6L5 10L1 6" />261 <path d="M5 10V2" />262 </svg>263 {checkingBreaches() ? "checking..." : "Check all breaches now"}264 </button>265 </div>266267 <div class="peach-health-sections">268 <HealthSection269 title="Breached"270 count={breachedEntries().length}271 expanded={expandedSections().breached}272 onToggle={() => toggleSection("breached")}273 emptyMessage="No breached entries"274 >275 <For each={breachedEntries()}>276 {(entry) => (277 <div class="peach-health-row">278 <div class="peach-health-row-info">279 <span class="peach-health-row-name">{entry.title}</span>280 <span class="peach-health-row-meta" style="color:var(--peach-breach);">281 {formatExposure(entry.breach ?? 0)}282 </span>283 </div>284 <button285 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"286 onClick={() => props.onFixEntry(entry)}287 >288 Fix289 <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;">290 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />291 </svg>292 </button>293 </div>294 )}295 </For>296 </HealthSection>297298 <HealthSection299 title="Reused"300 count={reusedGroups().length}301 expanded={expandedSections().reused}302 onToggle={() => toggleSection("reused")}303 loading={reusedLoading()}304 emptyMessage="No reused passwords"305 >306 <Show when={!reusedLoading()} fallback={<div class="peach-health-loading">Computing...</div>}>307 <For each={reusedGroups()}>308 {(group) => (309 <div class="peach-health-row">310 <div class="peach-health-row-info">311 <span class="peach-health-row-name">312 Used in {group.entries.length} places313 </span>314 <span class="peach-health-row-meta">315 {group.entries.map((e) => e.title).join(", ")}316 </span>317 </div>318 <button319 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"320 onClick={() => setViewingReusedGroup(group.entries)}321 >322 View323 <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;">324 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />325 </svg>326 </button>327 </div>328 )}329 </For>330 </Show>331 </HealthSection>332333 <HealthSection334 title="Weak"335 count={weakEntries().length}336 expanded={expandedSections().weak}337 onToggle={() => toggleSection("weak")}338 emptyMessage="No weak passwords"339 >340 <For each={weakEntries()}>341 {({ entry, score }) => (342 <div class="peach-health-row">343 <div class="peach-health-row-info">344 <span class="peach-health-row-name">345 {entry.url ? getDisplayDomain(entry.url) : entry.title}346 </span>347 <span class="peach-health-row-meta">348 score: {score} ({scoreLabel(score)})349 </span>350 </div>351 <button352 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"353 onClick={() => props.onFixEntry(entry)}354 >355 Fix356 <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;">357 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />358 </svg>359 </button>360 </div>361 )}362 </For>363 </HealthSection>364365 <HealthSection366 title="Old"367 count={oldEntries().length}368 expanded={expandedSections().old}369 onToggle={() => toggleSection("old")}370 emptyMessage="No old entries"371 >372 <For each={oldEntries()}>373 {(entry) => {374 const age = Date.now() - entry.modifiedAt;375 return (376 <div class="peach-health-row">377 <div class="peach-health-row-info">378 <span class="peach-health-row-name">379 {entry.url ? getDisplayDomain(entry.url) : entry.title}380 </span>381 <span class="peach-health-row-meta">382 last changed {formatRelativeTime(age)}383 </span>384 </div>385 <button386 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"387 onClick={() => props.onFixEntry(entry)}388 >389 Fix390 <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;">391 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />392 </svg>393 </button>394 </div>395 );396 }}397 </For>398 </HealthSection>399400 <HealthSection401 title="Passkeys"402 count={stalePasskeys().length}403 expanded={expandedSections().passkeys}404 onToggle={() => toggleSection("passkeys")}405 emptyMessage="No stale passkeys"406 >407 <For each={stalePasskeys()}>408 {(item) => (409 <div class="peach-health-row">410 <div class="peach-health-row-info">411 <span class="peach-health-row-name">{item.entry.title}</span>412 <span class="peach-health-row-meta">413 {item.status} • {item.entry.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}414 </span>415 </div>416 <button417 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"418 onClick={() => props.onFixEntry(item.entry)}419 >420 View421 <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;">422 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />423 </svg>424 </button>425 </div>426 )}427 </For>428 </HealthSection>429 </div>430 </div>431 </div>432433 <Show when={viewingReusedGroup()}>434 <VaultView435 entries={viewingReusedGroup()!}436 selectedEntryId={undefined}437 sortOrder="az"438 onSortOrderChange={() => {}}439 onSelectEntry={(entry) => {440 setViewingReusedGroup(null);441 props.onFixEntry(entry);442 }}443 onAdd={() => {}}444 onClose={() => setViewingReusedGroup(null)}445 />446 </Show>447 </>448 );449}450451interface HealthSectionProps {452 title: string;453 count: number;454 expanded: boolean;455 onToggle: () => void;456 loading?: boolean;457 emptyMessage: string;458 children: any;459}460461function HealthSection(props: HealthSectionProps) {462 return (463 <div class="peach-health-section">464 <button class="peach-health-section-header" onClick={props.onToggle}>465 <div class="peach-health-section-title-row">466 <span class="peach-health-count-badge">{props.count}</span>467 <span class="peach-health-section-title">{props.title}</span>468 </div>469 <svg470 class="peach-health-chevron"471 classList={{ "is-expanded": props.expanded }}472 width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"473 >474 <path d="M1 1L5 5L9 1" />475 </svg>476 </button>477 <Show when={props.expanded}>478 <div class="peach-health-section-body">479 <Show480 when={!props.loading && props.count > 0}481 fallback={482 <Show when={!props.loading}>483 <div class="peach-health-empty">{props.emptyMessage}</div>484 </Show>485 }486 >487 {props.children}488 </Show>489 </div>490 </Show>491 </div>492 );493}