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 3031 hidden?: boolean;32}3334function formatExposure(count: number): string {35 if (count >= 1000) return `${(count / 1000).toFixed(count % 1000 === 0 ? 0 : 1)}k exposures`;36 return `${count.toLocaleString()} ${count === 1 ? "exposure" : "exposures"}`;37}3839function formatRelativeTime(ms: number): string {40 const seconds = Math.floor(ms / 1000);41 if (seconds < 60) return "just now";42 const minutes = Math.floor(seconds / 60);43 if (minutes < 60) return `${minutes} minute${minutes !== 1 ? "s" : ""} ago`;44 const hours = Math.floor(minutes / 60);45 if (hours < 24) return `${hours} hour${hours !== 1 ? "s" : ""} ago`;46 const days = Math.floor(hours / 24);47 if (days < 30) return `${days} day${days !== 1 ? "s" : ""} ago`;48 const months = Math.floor(days / 30);49 if (months < 12) return `${months} month${months !== 1 ? "s" : ""} ago`;50 const years = Math.floor(months / 12);51 return `${years} year${years !== 1 ? "s" : ""} ago`;52}5354function scoreLabel(score: number): string {55 const labels = ["very weak", "weak", "fair", "good", "strong"];56 return labels[score] ?? "unknown";57}5859export function HealthView(props: HealthProps) {60 const [checkingBreaches, setCheckingBreaches] = createSignal(false);61 const [reusedGroups, setReusedGroups] = createSignal<ReusedGroup[]>([]);62 const [reusedLoading, setReusedLoading] = createSignal(true);63 const [expandedSections, setExpandedSections] = createSignal<Record<string, boolean>>({64 breached: true,65 reused: true,66 weak: true,67 old: true,68 passkeys: true,69 });70 const [viewingReusedGroup, setViewingReusedGroup] = createSignal<VaultEntry[] | null>(null);71 const [advancedReport, setAdvancedReport] = createSignal<AdvancedHealthReport | null>(null);72 73 74 const [advancedLoading, setAdvancedLoading] = createSignal<boolean>(!!props.isPro);7576 onMount(async () => {77 if (!props.isPro) return;78 try {79 const res = await sendMessage({ type: "GET_ADVANCED_HEALTH_REPORT" });80 if ((res as { ok: boolean }).ok) {81 setAdvancedReport((res as { ok: boolean; data?: AdvancedHealthReport }).data ?? null);82 }83 } finally {84 setAdvancedLoading(false);85 }86 });8788 const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;8990 const breachedEntries = createMemo(() =>91 props.entries92 .filter((e) => (e.breach ?? 0) > 0)93 .sort((a, b) => (b.breach ?? 0) - (a.breach ?? 0))94 );9596 const weakEntries = createMemo(() => {97 98 const scored = props.entries99 .filter((e) => e.type !== "note" && e.password)100 .map((entry) => ({ entry, score: zxcvbn(entry.password!).score }))101 .filter(({ score }) => score <= 1);102 scored.sort((a, b) => a.score - b.score);103 return scored;104 });105106 const oldEntries = createMemo(() =>107 props.entries108 .filter((e) => e.type === "login" && e.modifiedAt < Date.now() - ONE_YEAR_MS)109 .sort((a, b) => a.modifiedAt - b.modifiedAt)110 );111112 const stalePasskeys = createMemo(() =>113 props.entries114 .filter((e) => e.passkey !== undefined)115 .map((entry) => {116 const passkey = entry.passkey!;117 const lastActivity = passkey.lastUsedAt ?? passkey.createdAt ?? entry.createdAt;118 const ageMs = Date.now() - lastActivity;119 const status =120 passkey.signCount === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000121 ? "Unused"122 : ageMs >= ONE_YEAR_MS123 ? "Old"124 : null;125 return { entry, lastActivity, status };126 })127 .filter((item) => item.status !== null)128 .sort((a, b) => a.lastActivity - b.lastActivity)129 );130131 const entriesById = createMemo(() => {132 const map = new Map<string, VaultEntry>();133 for (const entry of props.entries) map.set(entry.id, entry);134 return map;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 const isLoading = () => reusedLoading() || (!!props.isPro && advancedLoading());189190 return (191 <>192 <div class="peach-overlay-view ov-enter-right" style={{ display: props.hidden ? "none" : "flex" }}>193 <div class="peach-overlay-header">194 <button195 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"196 onClick={props.onBack}197 aria-label="Back"198 >199 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">200 <path d="M6 1L1 6L6 11" />201 </svg>202 </button>203 <span class="peach-overlay-title">Advanced Vault Health</span>204 <div class="peach-overlay-spacer"></div>205 </div>206207 <div class="peach-health-body">208 <Show when={isLoading()}>209 <div class="peach-health-loading-screen" aria-busy="true" aria-live="polite">210 <div class="peach-health-loading-glyph" aria-hidden="true">211 <svg width="32" height="32" viewBox="0 0 32 32" fill="none">212 <circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="1.2" opacity="0.18" />213 <path d="M16 3a13 13 0 0 1 13 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">214 <animateTransform attributeName="transform" type="rotate" from="0 16 16" to="360 16 16" dur="1.2s" repeatCount="indefinite" />215 </path>216 </svg>217 </div>218 <h2 class="peach-health-loading-title">Checking your vault's health</h2>219 <p class="peach-health-loading-copy">220 Scanning for reused, breached, weak, and aging credentials.221 Just a moment…222 </p>223 <div class="peach-health-loading-track" aria-hidden="true">224 <div class="peach-health-loading-fill"></div>225 </div>226 </div>227 </Show>228 <Show when={!isLoading()}>229 <Show when={props.isPro && advancedReport() && advancedReport()!.remediation.length > 0}>230 <div class="peach-health-pro-queue">231 <div class="peach-health-pro-queue-head">232 <span class="peach-health-pro-queue-title">Remediation queue</span>233 <span class="peach-health-pro-queue-score">234 Vault score {advancedReport()!.vaultScore}/100235 </span>236 </div>237 <For each={advancedReport()!.remediation.slice(0, 8)}>238 {(item) => {239 const entry = () => entriesById().get(item.id);240 return (241 <div class="peach-health-pro-row">242 <div class="peach-health-pro-row-info">243 <span class="peach-health-pro-row-name">{item.title}</span>244 <span class="peach-health-pro-row-meta">245 {item.reasons.join(" · ")}246 </span>247 <div class="peach-health-pro-bar" aria-hidden="true">248 <div249 class="peach-health-pro-bar-fill"250 style={{ width: `${Math.min(item.score, 100)}%` }}251 ></div>252 </div>253 </div>254 <Show when={entry()}>255 {(target) => (256 <button257 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-pro-action"258 onClick={() => props.onFixEntry(target())}259 aria-label={`Update password for ${item.title}`}260 >261 Update262 </button>263 )}264 </Show>265 </div>266 );267 }}268 </For>269 </div>270 </Show>271272 <div class="peach-health-summary">273 <Show274 when={totalNeedsAttention() > 0}275 fallback={<span class="peach-health-summary-ok">Your vault looks healthy.</span>}276 >277 <span class="peach-health-summary-warn">278 {totalNeedsAttention()} {totalNeedsAttention() === 1 ? "entry needs" : "entries need"} attention279 </span>280 </Show>281 </div>282283 <div class="peach-health-check-row">284 <button285 class="peach-btn peach-btn--accent peach-btn--sm peach-health-check-btn"286 onClick={() => void handleCheckAllBreaches()}287 disabled={checkingBreaches()}288 >289 <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;">290 <path d="M9 6L5 10L1 6" />291 <path d="M5 10V2" />292 </svg>293 {checkingBreaches() ? "checking..." : "Check all breaches now"}294 </button>295 </div>296297 <div class="peach-health-sections">298 <HealthSection299 title="Breached"300 count={breachedEntries().length}301 expanded={expandedSections().breached}302 onToggle={() => toggleSection("breached")}303 emptyMessage="No breached entries"304 >305 <For each={breachedEntries()}>306 {(entry) => (307 <div class="peach-health-row">308 <div class="peach-health-row-info">309 <span class="peach-health-row-name">{entry.title}</span>310 <span class="peach-health-row-meta" style="color:var(--peach-breach);">311 {formatExposure(entry.breach ?? 0)}312 </span>313 </div>314 <button315 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"316 onClick={() => props.onFixEntry(entry)}317 >318 Fix319 <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;">320 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />321 </svg>322 </button>323 </div>324 )}325 </For>326 </HealthSection>327328 <HealthSection329 title="Reused"330 count={reusedGroups().length}331 expanded={expandedSections().reused}332 onToggle={() => toggleSection("reused")}333 loading={reusedLoading()}334 emptyMessage="No reused passwords"335 >336 <Show when={!reusedLoading()} fallback={<div class="peach-health-loading">Computing...</div>}>337 <For each={reusedGroups()}>338 {(group) => (339 <div class="peach-health-row">340 <div class="peach-health-row-info">341 <span class="peach-health-row-name">342 Used in {group.entries.length} places343 </span>344 <span class="peach-health-row-meta">345 {group.entries.map((e) => e.title).join(", ")}346 </span>347 </div>348 <button349 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"350 onClick={() => setViewingReusedGroup(group.entries)}351 >352 View353 <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;">354 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />355 </svg>356 </button>357 </div>358 )}359 </For>360 </Show>361 </HealthSection>362363 <HealthSection364 title="Weak"365 count={weakEntries().length}366 expanded={expandedSections().weak}367 onToggle={() => toggleSection("weak")}368 emptyMessage="No weak passwords"369 >370 <For each={weakEntries()}>371 {({ entry, score }) => (372 <div class="peach-health-row">373 <div class="peach-health-row-info">374 <span class="peach-health-row-name">375 {entry.url ? getDisplayDomain(entry.url) : entry.title}376 </span>377 <span class="peach-health-row-meta">378 score: {score} ({scoreLabel(score)})379 </span>380 </div>381 <button382 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"383 onClick={() => props.onFixEntry(entry)}384 >385 Fix386 <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;">387 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />388 </svg>389 </button>390 </div>391 )}392 </For>393 </HealthSection>394395 <HealthSection396 title="Old"397 count={oldEntries().length}398 expanded={expandedSections().old}399 onToggle={() => toggleSection("old")}400 emptyMessage="No old entries"401 >402 <For each={oldEntries()}>403 {(entry) => {404 const age = Date.now() - entry.modifiedAt;405 return (406 <div class="peach-health-row">407 <div class="peach-health-row-info">408 <span class="peach-health-row-name">409 {entry.url ? getDisplayDomain(entry.url) : entry.title}410 </span>411 <span class="peach-health-row-meta">412 last changed {formatRelativeTime(age)}413 </span>414 </div>415 <button416 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"417 onClick={() => props.onFixEntry(entry)}418 >419 Fix420 <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;">421 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />422 </svg>423 </button>424 </div>425 );426 }}427 </For>428 </HealthSection>429430 <HealthSection431 title="Passkeys"432 count={stalePasskeys().length}433 expanded={expandedSections().passkeys}434 onToggle={() => toggleSection("passkeys")}435 emptyMessage="No stale passkeys"436 >437 <For each={stalePasskeys()}>438 {(item) => (439 <div class="peach-health-row">440 <div class="peach-health-row-info">441 <span class="peach-health-row-name">{item.entry.title}</span>442 <span class="peach-health-row-meta">443 {item.status} • {item.entry.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}444 </span>445 </div>446 <button447 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"448 onClick={() => props.onFixEntry(item.entry)}449 >450 View451 <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;">452 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />453 </svg>454 </button>455 </div>456 )}457 </For>458 </HealthSection>459 </div>460 </Show>{}461 </div>462 </div>463464 <Show when={viewingReusedGroup()}>465 <VaultView466 entries={viewingReusedGroup()!}467 selectedEntryId={undefined}468 sortOrder="az"469 onSortOrderChange={() => {}}470 onSelectEntry={(entry) => {471 setViewingReusedGroup(null);472 props.onFixEntry(entry);473 }}474 onAdd={() => {}}475 onClose={() => setViewingReusedGroup(null)}476 />477 </Show>478 </>479 );480}481482interface HealthSectionProps {483 title: string;484 count: number;485 expanded: boolean;486 onToggle: () => void;487 loading?: boolean;488 emptyMessage: string;489 children: any;490}491492function HealthSection(props: HealthSectionProps) {493 return (494 <div class="peach-health-section">495 <button class="peach-health-section-header" onClick={props.onToggle}>496 <div class="peach-health-section-title-row">497 <span class="peach-health-count-badge">{props.count}</span>498 <span class="peach-health-section-title">{props.title}</span>499 </div>500 <svg501 class="peach-health-chevron"502 classList={{ "is-expanded": props.expanded }}503 width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"504 >505 <path d="M1 1L5 5L9 1" />506 </svg>507 </button>508 <Show when={props.expanded}>509 <div class="peach-health-section-body">510 <Show511 when={!props.loading && props.count > 0}512 fallback={513 <Show when={!props.loading}>514 <div class="peach-health-empty">{props.emptyMessage}</div>515 </Show>516 }517 >518 {props.children}519 </Show>520 </div>521 </Show>522 </div>523 );524}