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.type === "passkey")121 .map((entry) => {122 const lastActivity = entry.lastUsedAt ?? entry.createdAt;123 const ageMs = Date.now() - lastActivity;124 const status =125 (entry.signCount ?? 0) === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000126 ? "Unused"127 : ageMs >= ONE_YEAR_MS128 ? "Old"129 : null;130 return { entry, lastActivity, status };131 })132 .filter((item) => item.status !== null)133 .sort((a, b) => a.lastActivity - b.lastActivity)134 );135136 async function computeReused() {137 const loginEntries = props.entries.filter((e) => e.type !== "note" && e.password);138 if (loginEntries.length === 0) {139 setReusedGroups([]);140 setReusedLoading(false);141 return;142 }143 const hashes = await Promise.all(144 loginEntries.map(async (e) => ({ entry: e, hash: await sha256(e.password!) }))145 );146 const map = new Map<string, VaultEntry[]>();147 for (const { entry, hash } of hashes) {148 const group = map.get(hash) ?? [];149 group.push(entry);150 map.set(hash, group);151 }152 const groups: ReusedGroup[] = [];153 map.forEach((entries, hash) => {154 if (entries.length >= 2) {155 groups.push({ hash, entries });156 }157 });158 groups.sort((a, b) => b.entries.length - a.entries.length);159 setReusedGroups(groups);160 setReusedLoading(false);161 }162163 computeReused();164165 function toggleSection(key: string) {166 setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));167 }168169 async function handleCheckAllBreaches() {170 setCheckingBreaches(true);171 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });172 setCheckingBreaches(false);173 if ((res as { ok: boolean }).ok) {174 props.onRefresh();175 }176 }177178 const totalNeedsAttention = createMemo(179 () =>180 breachedEntries().length +181 reusedGroups().length +182 weakEntries().length + 183 oldEntries().length +184 stalePasskeys().length185 );186187 return (188 <>189 <div class="peach-overlay-view ov-enter-right" style="display:flex">190 <div class="peach-overlay-header">191 <button192 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"193 onClick={props.onBack}194 aria-label="Back"195 >196 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">197 <path d="M6 1L1 6L6 11" />198 </svg>199 </button>200 <span class="peach-overlay-title">vault health</span>201 <div class="peach-overlay-spacer"></div>202 </div>203204 <div class="peach-health-body">205 <Show when={props.isPro && advancedReport() && advancedReport()!.remediation.length > 0}>206 <div class="peach-health-pro-queue">207 <div class="peach-health-pro-queue-head">208 <span class="peach-health-pro-queue-title">Remediation queue</span>209 <span class="peach-health-pro-queue-score">210 Vault score {advancedReport()!.vaultScore}/100211 </span>212 </div>213 <For each={advancedReport()!.remediation.slice(0, 8)}>214 {(item) => (215 <div class="peach-health-pro-row">216 <div class="peach-health-pro-row-info">217 <span class="peach-health-pro-row-name">{item.title}</span>218 <span class="peach-health-pro-row-meta">219 {item.reasons.join(" · ")}220 </span>221 <div class="peach-health-pro-bar" aria-hidden="true">222 <div223 class="peach-health-pro-bar-fill"224 style={{ width: `${Math.min(item.score, 100)}%` }}225 ></div>226 </div>227 </div>228 <button229 class="peach-btn peach-btn--accent peach-btn--sm"230 onClick={() => void handleSmartChange(item.id)}231 disabled={smartChangingId() === item.id}232 >233 {smartChangingId() === item.id ? "opening…" : "Change this password"}234 </button>235 </div>236 )}237 </For>238 </div>239 </Show>240241 <div class="peach-health-summary">242 <Show243 when={totalNeedsAttention() > 0}244 fallback={<span class="peach-health-summary-ok">Your vault looks healthy.</span>}245 >246 <span class="peach-health-summary-warn">247 {totalNeedsAttention()} {totalNeedsAttention() === 1 ? "entry needs" : "entries need"} attention248 </span>249 </Show>250 </div>251252 <div class="peach-health-check-row">253 <button254 class="peach-btn peach-btn--accent peach-btn--sm peach-health-check-btn"255 onClick={() => void handleCheckAllBreaches()}256 disabled={checkingBreaches()}257 >258 <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;">259 <path d="M9 6L5 10L1 6" />260 <path d="M5 10V2" />261 </svg>262 {checkingBreaches() ? "checking..." : "Check all breaches now"}263 </button>264 </div>265266 <div class="peach-health-sections">267 <HealthSection268 title="Breached"269 count={breachedEntries().length}270 expanded={expandedSections().breached}271 onToggle={() => toggleSection("breached")}272 emptyMessage="No breached entries"273 >274 <For each={breachedEntries()}>275 {(entry) => (276 <div class="peach-health-row">277 <div class="peach-health-row-info">278 <span class="peach-health-row-name">{entry.title}</span>279 <span class="peach-health-row-meta" style="color:var(--peach-breach);">280 {formatExposure(entry.breach ?? 0)}281 </span>282 </div>283 <button284 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"285 onClick={() => props.onFixEntry(entry)}286 >287 Fix288 <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;">289 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />290 </svg>291 </button>292 </div>293 )}294 </For>295 </HealthSection>296297 <HealthSection298 title="Reused"299 count={reusedGroups().length}300 expanded={expandedSections().reused}301 onToggle={() => toggleSection("reused")}302 loading={reusedLoading()}303 emptyMessage="No reused passwords"304 >305 <Show when={!reusedLoading()} fallback={<div class="peach-health-loading">Computing...</div>}>306 <For each={reusedGroups()}>307 {(group) => (308 <div class="peach-health-row">309 <div class="peach-health-row-info">310 <span class="peach-health-row-name">311 Used in {group.entries.length} places312 </span>313 <span class="peach-health-row-meta">314 {group.entries.map((e) => e.title).join(", ")}315 </span>316 </div>317 <button318 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"319 onClick={() => setViewingReusedGroup(group.entries)}320 >321 View322 <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;">323 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />324 </svg>325 </button>326 </div>327 )}328 </For>329 </Show>330 </HealthSection>331332 <HealthSection333 title="Weak"334 count={weakEntries().length}335 expanded={expandedSections().weak}336 onToggle={() => toggleSection("weak")}337 emptyMessage="No weak passwords"338 >339 <For each={weakEntries()}>340 {({ entry, score }) => (341 <div class="peach-health-row">342 <div class="peach-health-row-info">343 <span class="peach-health-row-name">344 {entry.url ? getDisplayDomain(entry.url) : entry.title}345 </span>346 <span class="peach-health-row-meta">347 score: {score} ({scoreLabel(score)})348 </span>349 </div>350 <button351 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"352 onClick={() => props.onFixEntry(entry)}353 >354 Fix355 <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;">356 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />357 </svg>358 </button>359 </div>360 )}361 </For>362 </HealthSection>363364 <HealthSection365 title="Old"366 count={oldEntries().length}367 expanded={expandedSections().old}368 onToggle={() => toggleSection("old")}369 emptyMessage="No old entries"370 >371 <For each={oldEntries()}>372 {(entry) => {373 const age = Date.now() - entry.modifiedAt;374 return (375 <div class="peach-health-row">376 <div class="peach-health-row-info">377 <span class="peach-health-row-name">378 {entry.url ? getDisplayDomain(entry.url) : entry.title}379 </span>380 <span class="peach-health-row-meta">381 last changed {formatRelativeTime(age)}382 </span>383 </div>384 <button385 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"386 onClick={() => props.onFixEntry(entry)}387 >388 Fix389 <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;">390 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />391 </svg>392 </button>393 </div>394 );395 }}396 </For>397 </HealthSection>398399 <HealthSection400 title="Passkeys"401 count={stalePasskeys().length}402 expanded={expandedSections().passkeys}403 onToggle={() => toggleSection("passkeys")}404 emptyMessage="No stale passkeys"405 >406 <For each={stalePasskeys()}>407 {(item) => (408 <div class="peach-health-row">409 <div class="peach-health-row-info">410 <span class="peach-health-row-name">{item.entry.title}</span>411 <span class="peach-health-row-meta">412 {item.status} • {item.entry.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}413 </span>414 </div>415 <button416 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"417 onClick={() => props.onFixEntry(item.entry)}418 >419 View420 <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 </For>427 </HealthSection>428 </div>429 </div>430 </div>431432 <Show when={viewingReusedGroup()}>433 <VaultView434 entries={viewingReusedGroup()!}435 selectedEntryId={undefined}436 sortOrder="az"437 onSortOrderChange={() => {}}438 onSelectEntry={(entry) => {439 setViewingReusedGroup(null);440 props.onFixEntry(entry);441 }}442 onAdd={() => {}}443 onClose={() => setViewingReusedGroup(null)}444 />445 </Show>446 </>447 );448}449450interface HealthSectionProps {451 title: string;452 count: number;453 expanded: boolean;454 onToggle: () => void;455 loading?: boolean;456 emptyMessage: string;457 children: any;458}459460function HealthSection(props: HealthSectionProps) {461 return (462 <div class="peach-health-section">463 <button class="peach-health-section-header" onClick={props.onToggle}>464 <div class="peach-health-section-title-row">465 <span class="peach-health-count-badge">{props.count}</span>466 <span class="peach-health-section-title">{props.title}</span>467 </div>468 <svg469 class="peach-health-chevron"470 classList={{ "is-expanded": props.expanded }}471 width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"472 >473 <path d="M1 1L5 5L9 1" />474 </svg>475 </button>476 <Show when={props.expanded}>477 <div class="peach-health-section-body">478 <Show479 when={!props.loading && props.count > 0}480 fallback={481 <Show when={!props.loading}>482 <div class="peach-health-empty">{props.emptyMessage}</div>483 </Show>484 }485 >486 {props.children}487 </Show>488 </div>489 </Show>490 </div>491 );492}