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 [advancedLoading, setAdvancedLoading] = createSignal<boolean>(false);70 const [smartChangingId, setSmartChangingId] = createSignal<string | null>(null);7172 onMount(async () => {73 if (!props.isPro) return;74 setAdvancedLoading(true);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 async function handleSmartChange(entryId: string) {86 setSmartChangingId(entryId);87 const res = await sendMessage({88 type: "INITIATE_PASSWORD_CHANGE",89 payload: { id: entryId },90 });91 setSmartChangingId(null);92 if ((res as { ok: boolean }).ok) {93 94 95 96 window.close();97 }98 }99100 const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;101102 const breachedEntries = createMemo(() =>103 props.entries104 .filter((e) => (e.breach ?? 0) > 0)105 .sort((a, b) => (b.breach ?? 0) - (a.breach ?? 0))106 );107108 const weakEntries = createMemo(() => {109 110 const scored = props.entries111 .filter((e) => e.type !== "note" && e.password)112 .map((entry) => ({ entry, score: zxcvbn(entry.password!).score }))113 .filter(({ score }) => score <= 1);114 scored.sort((a, b) => a.score - b.score);115 return scored;116 });117118 const oldEntries = createMemo(() =>119 props.entries120 .filter((e) => e.type === "login" && e.modifiedAt < Date.now() - ONE_YEAR_MS)121 .sort((a, b) => a.modifiedAt - b.modifiedAt)122 );123124 const stalePasskeys = createMemo(() =>125 props.entries126 .filter((e) => e.passkey !== undefined)127 .map((entry) => {128 const passkey = entry.passkey!;129 const lastActivity = passkey.lastUsedAt ?? passkey.createdAt ?? entry.createdAt;130 const ageMs = Date.now() - lastActivity;131 const status =132 passkey.signCount === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000133 ? "Unused"134 : ageMs >= ONE_YEAR_MS135 ? "Old"136 : null;137 return { entry, lastActivity, status };138 })139 .filter((item) => item.status !== null)140 .sort((a, b) => a.lastActivity - b.lastActivity)141 );142143 async function computeReused() {144 const loginEntries = props.entries.filter((e) => e.type !== "note" && e.password);145 if (loginEntries.length === 0) {146 setReusedGroups([]);147 setReusedLoading(false);148 return;149 }150 const hashes = await Promise.all(151 loginEntries.map(async (e) => ({ entry: e, hash: await sha256(e.password!) }))152 );153 const map = new Map<string, VaultEntry[]>();154 for (const { entry, hash } of hashes) {155 const group = map.get(hash) ?? [];156 group.push(entry);157 map.set(hash, group);158 }159 const groups: ReusedGroup[] = [];160 map.forEach((entries, hash) => {161 if (entries.length >= 2) {162 groups.push({ hash, entries });163 }164 });165 groups.sort((a, b) => b.entries.length - a.entries.length);166 setReusedGroups(groups);167 setReusedLoading(false);168 }169170 computeReused();171172 function toggleSection(key: string) {173 setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));174 }175176 async function handleCheckAllBreaches() {177 setCheckingBreaches(true);178 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });179 setCheckingBreaches(false);180 if ((res as { ok: boolean }).ok) {181 props.onRefresh();182 }183 }184185 const totalNeedsAttention = createMemo(186 () =>187 breachedEntries().length +188 reusedGroups().length +189 weakEntries().length + 190 oldEntries().length +191 stalePasskeys().length192 );193194 return (195 <>196 <div class="peach-overlay-view ov-enter-right" style="display:flex">197 <div class="peach-overlay-header">198 <button199 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"200 onClick={props.onBack}201 aria-label="Back"202 >203 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">204 <path d="M6 1L1 6L6 11" />205 </svg>206 </button>207 <span class="peach-overlay-title">vault health</span>208 <div class="peach-overlay-spacer"></div>209 </div>210211 <div class="peach-health-body">212 <Show when={props.isPro && advancedLoading()}>213 <div class="peach-health-skeleton" aria-busy="true" aria-live="polite">214 <div class="peach-health-skeleton-head">215 <span class="peach-health-skeleton-title">Remediation queue</span>216 <span class="peach-health-skeleton-meta">217 <span class="peach-health-skeleton-dot" aria-hidden="true"></span>218 Scanning vault…219 </span>220 </div>221 <div class="peach-health-skeleton-row">222 <div>223 <div class="peach-health-skeleton-bar peach-health-skeleton-bar--name"></div>224 <div class="peach-health-skeleton-bar peach-health-skeleton-bar--meta"></div>225 </div>226 <div class="peach-health-skeleton-bar peach-health-skeleton-bar--cta"></div>227 </div>228 <div class="peach-health-skeleton-row">229 <div>230 <div class="peach-health-skeleton-bar peach-health-skeleton-bar--name"></div>231 <div class="peach-health-skeleton-bar peach-health-skeleton-bar--meta"></div>232 </div>233 <div class="peach-health-skeleton-bar peach-health-skeleton-bar--cta"></div>234 </div>235 <div class="peach-health-skeleton-row">236 <div>237 <div class="peach-health-skeleton-bar peach-health-skeleton-bar--name"></div>238 <div class="peach-health-skeleton-bar peach-health-skeleton-bar--meta"></div>239 </div>240 <div class="peach-health-skeleton-bar peach-health-skeleton-bar--cta"></div>241 </div>242 </div>243 </Show>244 <Show when={props.isPro && !advancedLoading() && advancedReport() && advancedReport()!.remediation.length > 0}>245 <div class="peach-health-pro-queue">246 <div class="peach-health-pro-queue-head">247 <span class="peach-health-pro-queue-title">Remediation queue</span>248 <span class="peach-health-pro-queue-score">249 Vault score {advancedReport()!.vaultScore}/100250 </span>251 </div>252 <For each={advancedReport()!.remediation.slice(0, 8)}>253 {(item) => (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 <button268 class="peach-btn peach-btn--accent peach-btn--sm"269 onClick={() => void handleSmartChange(item.id)}270 disabled={smartChangingId() === item.id}271 >272 {smartChangingId() === item.id ? "opening…" : "Change this password"}273 </button>274 </div>275 )}276 </For>277 </div>278 </Show>279280 <div class="peach-health-summary">281 <Show282 when={totalNeedsAttention() > 0}283 fallback={<span class="peach-health-summary-ok">Your vault looks healthy.</span>}284 >285 <span class="peach-health-summary-warn">286 {totalNeedsAttention()} {totalNeedsAttention() === 1 ? "entry needs" : "entries need"} attention287 </span>288 </Show>289 </div>290291 <div class="peach-health-check-row">292 <button293 class="peach-btn peach-btn--accent peach-btn--sm peach-health-check-btn"294 onClick={() => void handleCheckAllBreaches()}295 disabled={checkingBreaches()}296 >297 <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;">298 <path d="M9 6L5 10L1 6" />299 <path d="M5 10V2" />300 </svg>301 {checkingBreaches() ? "checking..." : "Check all breaches now"}302 </button>303 </div>304305 <div class="peach-health-sections">306 <HealthSection307 title="Breached"308 count={breachedEntries().length}309 expanded={expandedSections().breached}310 onToggle={() => toggleSection("breached")}311 emptyMessage="No breached entries"312 >313 <For each={breachedEntries()}>314 {(entry) => (315 <div class="peach-health-row">316 <div class="peach-health-row-info">317 <span class="peach-health-row-name">{entry.title}</span>318 <span class="peach-health-row-meta" style="color:var(--peach-breach);">319 {formatExposure(entry.breach ?? 0)}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 </For>334 </HealthSection>335336 <HealthSection337 title="Reused"338 count={reusedGroups().length}339 expanded={expandedSections().reused}340 onToggle={() => toggleSection("reused")}341 loading={reusedLoading()}342 emptyMessage="No reused passwords"343 >344 <Show when={!reusedLoading()} fallback={<div class="peach-health-loading">Computing...</div>}>345 <For each={reusedGroups()}>346 {(group) => (347 <div class="peach-health-row">348 <div class="peach-health-row-info">349 <span class="peach-health-row-name">350 Used in {group.entries.length} places351 </span>352 <span class="peach-health-row-meta">353 {group.entries.map((e) => e.title).join(", ")}354 </span>355 </div>356 <button357 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"358 onClick={() => setViewingReusedGroup(group.entries)}359 >360 View361 <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;">362 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />363 </svg>364 </button>365 </div>366 )}367 </For>368 </Show>369 </HealthSection>370371 <HealthSection372 title="Weak"373 count={weakEntries().length}374 expanded={expandedSections().weak}375 onToggle={() => toggleSection("weak")}376 emptyMessage="No weak passwords"377 >378 <For each={weakEntries()}>379 {({ entry, score }) => (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 score: {score} ({scoreLabel(score)})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 </For>401 </HealthSection>402403 <HealthSection404 title="Old"405 count={oldEntries().length}406 expanded={expandedSections().old}407 onToggle={() => toggleSection("old")}408 emptyMessage="No old entries"409 >410 <For each={oldEntries()}>411 {(entry) => {412 const age = Date.now() - entry.modifiedAt;413 return (414 <div class="peach-health-row">415 <div class="peach-health-row-info">416 <span class="peach-health-row-name">417 {entry.url ? getDisplayDomain(entry.url) : entry.title}418 </span>419 <span class="peach-health-row-meta">420 last changed {formatRelativeTime(age)}421 </span>422 </div>423 <button424 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"425 onClick={() => props.onFixEntry(entry)}426 >427 Fix428 <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;">429 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />430 </svg>431 </button>432 </div>433 );434 }}435 </For>436 </HealthSection>437438 <HealthSection439 title="Passkeys"440 count={stalePasskeys().length}441 expanded={expandedSections().passkeys}442 onToggle={() => toggleSection("passkeys")}443 emptyMessage="No stale passkeys"444 >445 <For each={stalePasskeys()}>446 {(item) => (447 <div class="peach-health-row">448 <div class="peach-health-row-info">449 <span class="peach-health-row-name">{item.entry.title}</span>450 <span class="peach-health-row-meta">451 {item.status} • {item.entry.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}452 </span>453 </div>454 <button455 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"456 onClick={() => props.onFixEntry(item.entry)}457 >458 View459 <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;">460 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />461 </svg>462 </button>463 </div>464 )}465 </For>466 </HealthSection>467 </div>468 </div>469 </div>470471 <Show when={viewingReusedGroup()}>472 <VaultView473 entries={viewingReusedGroup()!}474 selectedEntryId={undefined}475 sortOrder="az"476 onSortOrderChange={() => {}}477 onSelectEntry={(entry) => {478 setViewingReusedGroup(null);479 props.onFixEntry(entry);480 }}481 onAdd={() => {}}482 onClose={() => setViewingReusedGroup(null)}483 />484 </Show>485 </>486 );487}488489interface HealthSectionProps {490 title: string;491 count: number;492 expanded: boolean;493 onToggle: () => void;494 loading?: boolean;495 emptyMessage: string;496 children: any;497}498499function HealthSection(props: HealthSectionProps) {500 return (501 <div class="peach-health-section">502 <button class="peach-health-section-header" onClick={props.onToggle}>503 <div class="peach-health-section-title-row">504 <span class="peach-health-count-badge">{props.count}</span>505 <span class="peach-health-section-title">{props.title}</span>506 </div>507 <svg508 class="peach-health-chevron"509 classList={{ "is-expanded": props.expanded }}510 width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"511 >512 <path d="M1 1L5 5L9 1" />513 </svg>514 </button>515 <Show when={props.expanded}>516 <div class="peach-health-section-body">517 <Show518 when={!props.loading && props.count > 0}519 fallback={520 <Show when={!props.loading}>521 <div class="peach-health-empty">{props.emptyMessage}</div>522 </Show>523 }524 >525 {props.children}526 </Show>527 </div>528 </Show>529 </div>530 );531}