1import { createMemo, createSignal, For, Show } from "solid-js";2import { default as zxcvbn } from "zxcvbn";3import type { 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 onFixEntry: (entry: VaultEntry) => void;26 onBack: () => void;27 onRefresh: () => void;28}2930function formatExposure(count: number): string {31 if (count >= 1000) return `${(count / 1000).toFixed(count % 1000 === 0 ? 0 : 1)}k exposures`;32 return `${count.toLocaleString()} ${count === 1 ? "exposure" : "exposures"}`;33}3435function formatRelativeTime(ms: number): string {36 const seconds = Math.floor(ms / 1000);37 if (seconds < 60) return "just now";38 const minutes = Math.floor(seconds / 60);39 if (minutes < 60) return `${minutes} minute${minutes !== 1 ? "s" : ""} ago`;40 const hours = Math.floor(minutes / 60);41 if (hours < 24) return `${hours} hour${hours !== 1 ? "s" : ""} ago`;42 const days = Math.floor(hours / 24);43 if (days < 30) return `${days} day${days !== 1 ? "s" : ""} ago`;44 const months = Math.floor(days / 30);45 if (months < 12) return `${months} month${months !== 1 ? "s" : ""} ago`;46 const years = Math.floor(months / 12);47 return `${years} year${years !== 1 ? "s" : ""} ago`;48}4950function scoreLabel(score: number): string {51 const labels = ["very weak", "weak", "fair", "good", "strong"];52 return labels[score] ?? "unknown";53}5455export function HealthView(props: HealthProps) {56 const [checkingBreaches, setCheckingBreaches] = createSignal(false);57 const [reusedGroups, setReusedGroups] = createSignal<ReusedGroup[]>([]);58 const [reusedLoading, setReusedLoading] = createSignal(true);59 const [expandedSections, setExpandedSections] = createSignal<Record<string, boolean>>({60 breached: true,61 reused: true,62 weak: true,63 old: true,64 passkeys: true,65 });66 const [viewingReusedGroup, setViewingReusedGroup] = createSignal<VaultEntry[] | null>(null);6768 const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;6970 const breachedEntries = createMemo(() =>71 props.entries72 .filter((e) => (e.breach ?? 0) > 0)73 .sort((a, b) => (b.breach ?? 0) - (a.breach ?? 0))74 );7576 const weakEntries = createMemo(() => {77 78 const scored = props.entries79 .filter((e) => e.type !== "note" && e.password)80 .map((entry) => ({ entry, score: zxcvbn(entry.password!).score }))81 .filter(({ score }) => score <= 1);82 scored.sort((a, b) => a.score - b.score);83 return scored;84 });8586 const oldEntries = createMemo(() =>87 props.entries88 .filter((e) => e.type === "login" && e.modifiedAt < Date.now() - ONE_YEAR_MS)89 .sort((a, b) => a.modifiedAt - b.modifiedAt)90 );9192 const stalePasskeys = createMemo(() =>93 props.entries94 .filter((e) => e.type === "passkey")95 .map((entry) => {96 const lastActivity = entry.lastUsedAt ?? entry.createdAt;97 const ageMs = Date.now() - lastActivity;98 const status =99 (entry.signCount ?? 0) === 0 && ageMs >= 30 * 24 * 60 * 60 * 1000100 ? "Unused"101 : ageMs >= ONE_YEAR_MS102 ? "Old"103 : null;104 return { entry, lastActivity, status };105 })106 .filter((item) => item.status !== null)107 .sort((a, b) => a.lastActivity - b.lastActivity)108 );109110 async function computeReused() {111 const loginEntries = props.entries.filter((e) => e.type !== "note" && e.password);112 if (loginEntries.length === 0) {113 setReusedGroups([]);114 setReusedLoading(false);115 return;116 }117 const hashes = await Promise.all(118 loginEntries.map(async (e) => ({ entry: e, hash: await sha256(e.password!) }))119 );120 const map = new Map<string, VaultEntry[]>();121 for (const { entry, hash } of hashes) {122 const group = map.get(hash) ?? [];123 group.push(entry);124 map.set(hash, group);125 }126 const groups: ReusedGroup[] = [];127 map.forEach((entries, hash) => {128 if (entries.length >= 2) {129 groups.push({ hash, entries });130 }131 });132 groups.sort((a, b) => b.entries.length - a.entries.length);133 setReusedGroups(groups);134 setReusedLoading(false);135 }136137 computeReused();138139 function toggleSection(key: string) {140 setExpandedSections((prev) => ({ ...prev, [key]: !prev[key] }));141 }142143 async function handleCheckAllBreaches() {144 setCheckingBreaches(true);145 const res = await sendMessage({ type: "CHECK_ALL_BREACHES" });146 setCheckingBreaches(false);147 if ((res as { ok: boolean }).ok) {148 props.onRefresh();149 }150 }151152 const totalNeedsAttention = createMemo(153 () =>154 breachedEntries().length +155 reusedGroups().length +156 weakEntries().length + 157 oldEntries().length +158 stalePasskeys().length159 );160161 return (162 <>163 <div class="peach-overlay-view ov-enter-right" style="display:flex">164 <div class="peach-overlay-header">165 <button166 class="peach-btn peach-btn--ghost peach-btn--sm peach-icon-btn"167 onClick={props.onBack}168 aria-label="Back"169 >170 <svg width="7" height="12" viewBox="0 0 7 12" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round">171 <path d="M6 1L1 6L6 11" />172 </svg>173 </button>174 <span class="peach-overlay-title">vault health</span>175 <div class="peach-overlay-spacer"></div>176 </div>177178 <div class="peach-health-body">179 <div class="peach-health-summary">180 <Show181 when={totalNeedsAttention() > 0}182 fallback={<span class="peach-health-summary-ok">Your vault looks healthy.</span>}183 >184 <span class="peach-health-summary-warn">185 {totalNeedsAttention()} {totalNeedsAttention() === 1 ? "entry needs" : "entries need"} attention186 </span>187 </Show>188 </div>189190 <div class="peach-health-check-row">191 <button192 class="peach-btn peach-btn--accent peach-btn--sm peach-health-check-btn"193 onClick={() => void handleCheckAllBreaches()}194 disabled={checkingBreaches()}195 >196 <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;">197 <path d="M9 6L5 10L1 6" />198 <path d="M5 10V2" />199 </svg>200 {checkingBreaches() ? "checking..." : "Check all breaches now"}201 </button>202 </div>203204 <div class="peach-health-sections">205 <HealthSection206 title="Breached"207 count={breachedEntries().length}208 expanded={expandedSections().breached}209 onToggle={() => toggleSection("breached")}210 emptyMessage="No breached entries"211 >212 <For each={breachedEntries()}>213 {(entry) => (214 <div class="peach-health-row">215 <div class="peach-health-row-info">216 <span class="peach-health-row-name">{entry.title}</span>217 <span class="peach-health-row-meta" style="color:var(--peach-breach);">218 {formatExposure(entry.breach ?? 0)}219 </span>220 </div>221 <button222 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"223 onClick={() => props.onFixEntry(entry)}224 >225 Fix226 <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;">227 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />228 </svg>229 </button>230 </div>231 )}232 </For>233 </HealthSection>234235 <HealthSection236 title="Reused"237 count={reusedGroups().length}238 expanded={expandedSections().reused}239 onToggle={() => toggleSection("reused")}240 loading={reusedLoading()}241 emptyMessage="No reused passwords"242 >243 <Show when={!reusedLoading()} fallback={<div class="peach-health-loading">Computing...</div>}>244 <For each={reusedGroups()}>245 {(group) => (246 <div class="peach-health-row">247 <div class="peach-health-row-info">248 <span class="peach-health-row-name">249 Used in {group.entries.length} places250 </span>251 <span class="peach-health-row-meta">252 {group.entries.map((e) => e.title).join(", ")}253 </span>254 </div>255 <button256 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"257 onClick={() => setViewingReusedGroup(group.entries)}258 >259 View260 <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;">261 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />262 </svg>263 </button>264 </div>265 )}266 </For>267 </Show>268 </HealthSection>269270 <HealthSection271 title="Weak"272 count={weakEntries().length}273 expanded={expandedSections().weak}274 onToggle={() => toggleSection("weak")}275 emptyMessage="No weak passwords"276 >277 <For each={weakEntries()}>278 {({ entry, score }) => (279 <div class="peach-health-row">280 <div class="peach-health-row-info">281 <span class="peach-health-row-name">282 {entry.url ? getDisplayDomain(entry.url) : entry.title}283 </span>284 <span class="peach-health-row-meta">285 score: {score} ({scoreLabel(score)})286 </span>287 </div>288 <button289 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"290 onClick={() => props.onFixEntry(entry)}291 >292 Fix293 <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;">294 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />295 </svg>296 </button>297 </div>298 )}299 </For>300 </HealthSection>301302 <HealthSection303 title="Old"304 count={oldEntries().length}305 expanded={expandedSections().old}306 onToggle={() => toggleSection("old")}307 emptyMessage="No old entries"308 >309 <For each={oldEntries()}>310 {(entry) => {311 const age = Date.now() - entry.modifiedAt;312 return (313 <div class="peach-health-row">314 <div class="peach-health-row-info">315 <span class="peach-health-row-name">316 {entry.url ? getDisplayDomain(entry.url) : entry.title}317 </span>318 <span class="peach-health-row-meta">319 last changed {formatRelativeTime(age)}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 }}334 </For>335 </HealthSection>336337 <HealthSection338 title="Passkeys"339 count={stalePasskeys().length}340 expanded={expandedSections().passkeys}341 onToggle={() => toggleSection("passkeys")}342 emptyMessage="No stale passkeys"343 >344 <For each={stalePasskeys()}>345 {(item) => (346 <div class="peach-health-row">347 <div class="peach-health-row-info">348 <span class="peach-health-row-name">{item.entry.title}</span>349 <span class="peach-health-row-meta">350 {item.status} • {item.entry.rpId} • {formatRelativeTime(Date.now() - item.lastActivity)}351 </span>352 </div>353 <button354 class="peach-btn peach-btn--ghost peach-btn--sm peach-health-fix-btn"355 onClick={() => props.onFixEntry(item.entry)}356 >357 View358 <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;">359 <path d="M1 5L5 9M5 9L5 5M5 9L1 9" />360 </svg>361 </button>362 </div>363 )}364 </For>365 </HealthSection>366 </div>367 </div>368 </div>369370 <Show when={viewingReusedGroup()}>371 <VaultView372 entries={viewingReusedGroup()!}373 selectedEntryId={undefined}374 sortOrder="az"375 onSortOrderChange={() => {}}376 onSelectEntry={(entry) => {377 setViewingReusedGroup(null);378 props.onFixEntry(entry);379 }}380 onAdd={() => {}}381 onClose={() => setViewingReusedGroup(null)}382 />383 </Show>384 </>385 );386}387388interface HealthSectionProps {389 title: string;390 count: number;391 expanded: boolean;392 onToggle: () => void;393 loading?: boolean;394 emptyMessage: string;395 children: any;396}397398function HealthSection(props: HealthSectionProps) {399 return (400 <div class="peach-health-section">401 <button class="peach-health-section-header" onClick={props.onToggle}>402 <div class="peach-health-section-title-row">403 <span class="peach-health-count-badge">{props.count}</span>404 <span class="peach-health-section-title">{props.title}</span>405 </div>406 <svg407 class="peach-health-chevron"408 classList={{ "is-expanded": props.expanded }}409 width="10" height="6" viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"410 >411 <path d="M1 1L5 5L9 1" />412 </svg>413 </button>414 <Show when={props.expanded}>415 <div class="peach-health-section-body">416 <Show417 when={!props.loading && props.count > 0}418 fallback={419 <Show when={!props.loading}>420 <div class="peach-health-empty">{props.emptyMessage}</div>421 </Show>422 }423 >424 {props.children}425 </Show>426 </div>427 </Show>428 </div>429 );430}