import { createMemo, createSignal, For, onMount, Show } from "solid-js"; import { zxcvbn, zxcvbnOptions } from "@zxcvbn-ts/core"; import * as zxcvbnCommon from "@zxcvbn-ts/language-common"; import * as zxcvbnEn from "@zxcvbn-ts/language-en"; import type { AdvancedHealthReport, VaultEntry } from "../../shared/types"; import { getDisplayDomain } from "../../shared/domain"; import { sendMessage } from "../App"; import { VaultView } from "./Vault"; // Replaces the unmaintained `zxcvbn` package (LV-8; see Lock.tsx) — this file // was missed by that migration. Health.tsx is already its own route-level // lazy chunk, so a static import here carries the same load-timing as the // dynamic imports used elsewhere; it just needs the dictionaries configured // once before first use. zxcvbnOptions.setOptions({ translations: zxcvbnEn.translations, graphs: zxcvbnCommon.adjacencyGraphs, dictionary: { ...zxcvbnCommon.dictionary, ...zxcvbnEn.dictionary }, }); function sha256(plain: string): Promise { const encoder = new TextEncoder(); const data = encoder.encode(plain); return crypto.subtle.digest("SHA-256", data).then((buf) => { return Array.from(new Uint8Array(buf)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); }); } interface ReusedGroup { hash: string; entries: VaultEntry[]; } interface HealthProps { entries: VaultEntry[]; isPro?: boolean; onFixEntry: (entry: VaultEntry) => void; onBack: () => void; onRefresh: () => void; /** Set by App while an entry edit is in progress so the view stays mounted * but invisible — avoids re-running computeReused() on every back-nav. */ hidden?: boolean; } function formatExposure(count: number): string { if (count >= 1000) return `${(count / 1000).toFixed(count % 1000 === 0 ? 0 : 1)}k exposures`; return `${count.toLocaleString()} ${count === 1 ? "exposure" : "exposures"}`; } function formatRelativeTime(ms: number): string { const seconds = Math.floor(ms / 1000); if (seconds < 60) return "just now"; const minutes = Math.floor(seconds / 60); if (minutes < 60) return `${minutes} minute${minutes !== 1 ? "s" : ""} ago`; const hours = Math.floor(minutes / 60); if (hours < 24) return `${hours} hour${hours !== 1 ? "s" : ""} ago`; const days = Math.floor(hours / 24); if (days < 30) return `${days} day${days !== 1 ? "s" : ""} ago`; const months = Math.floor(days / 30); if (months < 12) return `${months} month${months !== 1 ? "s" : ""} ago`; const years = Math.floor(months / 12); return `${years} year${years !== 1 ? "s" : ""} ago`; } function scoreLabel(score: number): string { const labels = ["very weak", "weak", "fair", "good", "strong"]; return labels[score] ?? "unknown"; } export function HealthView(props: HealthProps) { const [checkingBreaches, setCheckingBreaches] = createSignal(false); const [reusedGroups, setReusedGroups] = createSignal([]); const [reusedLoading, setReusedLoading] = createSignal(true); const [expandedSections, setExpandedSections] = createSignal>({ breached: true, reused: true, weak: true, old: true, passkeys: true, }); const [viewingReusedGroup, setViewingReusedGroup] = createSignal(null); const [advancedReport, setAdvancedReport] = createSignal(null); // Start true for Pro users so the loading screen is visible on the very first render — // previously initialized to false and flipped in onMount, which left a blank-body flash. const [advancedLoading, setAdvancedLoading] = createSignal(!!props.isPro); onMount(async () => { if (!props.isPro) return; try { const res = await sendMessage({ type: "GET_ADVANCED_HEALTH_REPORT" }); if ((res as { ok: boolean }).ok) { setAdvancedReport((res as { ok: boolean; data?: AdvancedHealthReport }).data ?? null); } } finally { setAdvancedLoading(false); } }); const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; const breachedEntries = createMemo(() => props.entries .filter((e) => (e.breach ?? 0) > 0) .sort((a, b) => (b.breach ?? 0) - (a.breach ?? 0)) ); const weakEntries = createMemo(() => { // Precompute scores once — zxcvbn is expensive, avoid calling it multiple times per entry const scored = props.entries .filter((e) => e.type !== "note" && e.password) .map((entry) => ({ entry, score: zxcvbn(entry.password!).score })) .filter(({ score }) => score <= 1); scored.sort((a, b) => a.score - b.score); return scored; }); const oldEntries = createMemo(() => props.entries .filter((e) => e.type === "login" && e.modifiedAt < Date.now() - ONE_YEAR_MS) .sort((a, b) => a.modifiedAt - b.modifiedAt) ); const stalePasskeys = createMemo(() => props.entries .filter((e) => e.passkey !== undefined) .map((entry) => { const passkey = entry.passkey!; const lastActivity = passkey.lastUsedAt ?? passkey.createdAt ?? entry.createdAt; const ageMs = Date.now() - lastActivity; const status = passkey.signCount === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000 ? "Unused" : ageMs >= ONE_YEAR_MS ? "Old" : null; return { entry, lastActivity, status }; }) .filter((item) => item.status !== null) .sort((a, b) => a.lastActivity - b.lastActivity) ); const entriesById = createMemo(() => { const map = new Map(); for (const entry of props.entries) map.set(entry.id, entry); return map; }); async function computeReused() { const loginEntries = props.entries.filter((e) => e.type !== "note" && e.password); if (loginEntries.length === 0) { setReusedGroups([]); setReusedLoading(false); return; } const hashes = await Promise.all( loginEntries.map(async (e) => ({ entry: e, hash: await sha256(e.password!) })) ); const map = new Map(); for (const { entry, hash } of hashes) { const group = map.get(hash) ?? []; group.push(entry); map.set(hash, group); } const groups: ReusedGroup[] = []; map.forEach((entries, hash) => { if (entries.length >= 2) { groups.push({ hash, entries }); } }); groups.sort((a, b) => b.entries.length - a.entries.length); setReusedGroups(groups); setReusedLoading(false); } computeReused(); function toggleSection(key: string) { setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] })); } async function handleCheckAllBreaches() { setCheckingBreaches(true); const res = await sendMessage({ type: "CHECK_ALL_BREACHES" }); setCheckingBreaches(false); if ((res as { ok: boolean }).ok) { props.onRefresh(); } } const totalNeedsAttention = createMemo( () => breachedEntries().length + reusedGroups().length + weakEntries().length + // weakEntries returns { entry, score }[] now oldEntries().length + stalePasskeys().length ); const isLoading = () => reusedLoading() || (!!props.isPro && advancedLoading()); return ( <>
Advanced Vault Health

Checking your vault's health

Scanning for reused, breached, weak, and aging credentials. Just a moment…

0}>
Remediation queue Vault score {advancedReport()!.vaultScore}/100
{(item) => { const entry = () => entriesById().get(item.id); return (
{item.title} {item.reasons.join(" · ")}
{(target) => ( )}
); }}
0} fallback={Your vault looks healthy.} > {totalNeedsAttention()} {totalNeedsAttention() === 1 ? "entry needs" : "entries need"} attention
toggleSection("breached")} emptyMessage="No breached entries" > {(entry) => (
{entry.title} {formatExposure(entry.breach ?? 0)}
)}
toggleSection("reused")} loading={reusedLoading()} emptyMessage="No reused passwords" > Computing...
}> {(group) => (
Used in {group.entries.length} places {group.entries.map((e) => e.title).join(", ")}
)}
toggleSection("weak")} emptyMessage="No weak passwords" > {({ entry, score }) => (
{entry.url ? getDisplayDomain(entry.url) : entry.title} score: {score} ({scoreLabel(score)})
)}
toggleSection("old")} emptyMessage="No old entries" > {(entry) => { const age = Date.now() - entry.modifiedAt; return (
{entry.url ? getDisplayDomain(entry.url) : entry.title} last changed {formatRelativeTime(age)}
); }}
toggleSection("passkeys")} emptyMessage="No stale passkeys" > {(item) => (
{item.entry.title} {item.status} • {item.entry.passkey?.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}
)}
{/* end !isLoading() */}
{}} onSelectEntry={(entry) => { setViewingReusedGroup(null); props.onFixEntry(entry); }} onAdd={() => {}} onClose={() => setViewingReusedGroup(null)} /> ); } interface HealthSectionProps { title: string; count: number; expanded: boolean; onToggle: () => void; loading?: boolean; emptyMessage: string; children: any; } function HealthSection(props: HealthSectionProps) { return (
0} fallback={
{props.emptyMessage}
} > {props.children}
); }