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);72 const [smartChangingId, setSmartChangingId] = createSignal<string | null>(null);7374 onMount(async () => {75 if (!props.isPro) return;76 try {77 const res = await sendMessage({ type: "GET_ADVANCED_HEALTH_REPORT" });78 if ((res as { ok: boolean }).ok) {79 setAdvancedReport((res as { ok: boolean; data?: AdvancedHealthReport }).data ?? null);80 }81 } finally {82 setAdvancedLoading(false);83 }84 });8586 async function handleSmartChange(entryId: string) {87 setSmartChangingId(entryId);88 const res = await sendMessage({89 type: "INITIATE_PASSWORD_CHANGE",90 payload: { id: entryId },91 }) as { ok: boolean; error?: string };92 setSmartChangingId(null);93 if (res.ok) {94 95 96 97 window.close();98 } else if (res.error === "no_reset_url") {99 100 props.onFixEntry(props.entries.find((e) => e.id === entryId)!);101 }102 }103104 const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;105106 const breachedEntries = createMemo(() =>107 props.entries108 .filter((e) => (e.breach ?? 0) > 0)109 .sort((a, b) => (b.breach ?? 0) - (a.breach ?? 0))110 );111112 const weakEntries = createMemo(() => {113 114 const scored = props.entries115 .filter((e) => e.type !== "note" && e.password)116 .map((entry) => ({ entry, score: zxcvbn(entry.password!).score }))117 .filter(({ score }) => score <= 1);118 scored.sort((a, b) => a.score - b.score);119 return scored;120 });121122 const oldEntries = createMemo(() =>123 props.entries124 .filter((e) => e.type === "login" && e.modifiedAt < Date.now() - ONE_YEAR_MS)125 .sort((a, b) => a.modifiedAt - b.modifiedAt)126 );127128 const stalePasskeys = createMemo(() =>129 props.entries130 .filter((e) => e.passkey !== undefined)131 .map((entry) => {132 const passkey = entry.passkey!;133 const lastActivity = passkey.lastUsedAt ?? passkey.createdAt ?? entry.createdAt;134 const ageMs = Date.now() - lastActivity;135 const status =136 passkey.signCount === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000137 ? "Unused"138 : ageMs >= ONE_YEAR_MS139 ? "Old"140 : null;141 return { entry, lastActivity, status };142 })143 .filter((item) => item.status !== null)144 .sort((a, b) => a.lastActivity - b.lastActivity)145 );146147 async function computeReused() {148 const loginEntries = props.entries.filter((e) => e.type !== "note" && e.password);149 if (loginEntries.length === 0) {150 setReusedGroups([]);151 setReusedLoading(false);152 return;153 }154 const hashes = await Promise.all(155 loginEntries.map(async (e) => ({ entry: e, hash: await sha256(e.password!) }))156 );157 const map = new Map<string, VaultEntry[]>();158 for (const { entry, hash } of hashes) {159 const group = map.get(hash) ?? [];160 group.push(entry);161 map.set(hash, group);162 }163 const groups: ReusedGroup[] = [];164 map.forEach((entries, hash) => {165 if (entries.length >= 2) {166 groups.push({ hash, entries });167 }168 });169 groups.sort((a, b) => b.entries.length - a.entries.length);170 setReusedGroups(groups);171 setReusedLoading(false);172 }173174 computeReused();175176 function toggleSection(key: string) {177 setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));178 }179180 async function handleCheckAllBreaches() {181 setCheckingBreaches(true);182 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });183 setCheckingBreaches(false);184 if ((res as { ok: boolean }).ok) {185 props.onRefresh();186 }187 }188189 const totalNeedsAttention = createMemo(190 () =>191 breachedEntries().length +192 reusedGroups().length +193 weakEntries().length + 194 oldEntries().length +195 stalePasskeys().length196 );197198 return (199 <>200 <div class="peach-overlay-view ov-enter-right" style="display:flex">201 <div class="peach-overlay-header">202 <button203 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"204 onClick={props.onBack}205 aria-label="Back"206 >207 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">208 <path d="M6 1L1 6L6 11" />209 </svg>210 </button>211 <span class="peach-overlay-title">vault health</span>212 <div class="peach-overlay-spacer"></div>213 </div>214215 <div class="peach-health-body">216 <Show when={props.isPro && advancedLoading()}>217 <div class="peach-health-loading-screen" aria-busy="true" aria-live="polite">218 <div class="peach-health-loading-glyph" aria-hidden="true">219 <svg width="32" height="32" viewBox="0 0 32 32" fill="none">220 <circle cx="16" cy="16" r="13" stroke="currentColor" stroke-width="1.2" opacity="0.18" />221 <path d="M16 3a13 13 0 0 1 13 13" stroke="currentColor" stroke-width="1.6" stroke-linecap="round">222 <animateTransform attributeName="transform" type="rotate" from="0 16 16" to="360 16 16" dur="1.2s" repeatCount="indefinite" />223 </path>224 </svg>225 </div>226 <h2 class="peach-health-loading-title">Checking your vault's health</h2>227 <p class="peach-health-loading-copy">228 Scanning for reused, breached, weak, and aging credentials.229 Just a moment…230 </p>231 <div class="peach-health-loading-track" aria-hidden="true">232 <div class="peach-health-loading-fill"></div>233 </div>234 </div>235 </Show>236 <Show when={props.isPro && !advancedLoading() && advancedReport() && advancedReport()!.remediation.length > 0}>237 <div class="peach-health-pro-queue">238 <div class="peach-health-pro-queue-head">239 <span class="peach-health-pro-queue-title">Remediation queue</span>240 <span class="peach-health-pro-queue-score">241 Vault score {advancedReport()!.vaultScore}/100242 </span>243 </div>244 <For each={advancedReport()!.remediation.slice(0, 8)}>245 {(item) => (246 <div class="peach-health-pro-row">247 <div class="peach-health-pro-row-info">248 <span class="peach-health-pro-row-name">{item.title}</span>249 <span class="peach-health-pro-row-meta">250 {item.reasons.join(" · ")}251 </span>252 <div class="peach-health-pro-bar" aria-hidden="true">253 <div254 class="peach-health-pro-bar-fill"255 style={{ width: `${Math.min(item.score, 100)}%` }}256 ></div>257 </div>258 </div>259 <button260 class="peach-btn peach-btn--accent peach-btn--sm"261 onClick={() => void handleSmartChange(item.id)}262 disabled={smartChangingId() === item.id}263 >264 {smartChangingId() === item.id ? "opening…" : "Change this password"}265 </button>266 </div>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 </div>461 </div>462463 <Show when={viewingReusedGroup()}>464 <VaultView465 entries={viewingReusedGroup()!}466 selectedEntryId={undefined}467 sortOrder="az"468 onSortOrderChange={() => {}}469 onSelectEntry={(entry) => {470 setViewingReusedGroup(null);471 props.onFixEntry(entry);472 }}473 onAdd={() => {}}474 onClose={() => setViewingReusedGroup(null)}475 />476 </Show>477 </>478 );479}480481interface HealthSectionProps {482 title: string;483 count: number;484 expanded: boolean;485 onToggle: () => void;486 loading?: boolean;487 emptyMessage: string;488 children: any;489}490491function HealthSection(props: HealthSectionProps) {492 return (493 <div class="peach-health-section">494 <button class="peach-health-section-header" onClick={props.onToggle}>495 <div class="peach-health-section-title-row">496 <span class="peach-health-count-badge">{props.count}</span>497 <span class="peach-health-section-title">{props.title}</span>498 </div>499 <svg500 class="peach-health-chevron"501 classList={{ "is-expanded": props.expanded }}502 width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"503 >504 <path d="M1 1L5 5L9 1" />505 </svg>506 </button>507 <Show when={props.expanded}>508 <div class="peach-health-section-body">509 <Show510 when={!props.loading && props.count > 0}511 fallback={512 <Show when={!props.loading}>513 <div class="peach-health-empty">{props.emptyMessage}</div>514 </Show>515 }516 >517 {props.children}518 </Show>519 </div>520 </Show>521 </div>522 );523}