import { useEffect, useMemo, useState } from 'preact/hooks'; import { AlertTriangle, CheckCircle2, ExternalLink, Eye, EyeOff, RefreshCw, ScanSearch, ShieldAlert, ShieldCheck, Unplug } from 'lucide-preact'; import { Link } from 'wouter'; import { maskSecret } from '@/components/vault/vault-page-helpers'; import { getPasswordSecurityState, readPasswordSecurityState, startPasswordSecurityScan, subscribePasswordSecurityState } from '@/lib/password-security-cache'; import { t } from '@/lib/i18n'; import type { Cipher } from '@/lib/types'; interface PasswordSecurityPageProps { ciphers: Cipher[]; loading: boolean; } type PasswordSecurityFilter = 'exposed' | 'reused' | 'weak' | 'all'; function vaultFingerprint(ciphers: Cipher[]): string { return JSON.stringify(ciphers.map((cipher) => ({ id: cipher.id, type: cipher.type, revisionDate: cipher.revisionDate || '', deletedDate: cipher.deletedDate || (cipher as { deletedAt?: string | null }).deletedAt || '', }))); } function formatCheckedAt(value: number): string { return new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(value); } export default function PasswordSecurityPage(props: PasswordSecurityPageProps) { const fingerprint = vaultFingerprint(props.ciphers); const [securityState, setSecurityState] = useState(() => getPasswordSecurityState(fingerprint)); const [filter, setFilter] = useState('all'); const [revealedPasswordIds, setRevealedPasswordIds] = useState>(() => new Set()); useEffect(() => { setSecurityState(getPasswordSecurityState(fingerprint)); setFilter('all'); setRevealedPasswordIds(new Set()); return subscribePasswordSecurityState(() => { const next = readPasswordSecurityState(fingerprint); if (next) setSecurityState(next); }); }, [fingerprint]); const { report, scannedAt, scanning, progress, scanError } = securityState; const eligibleCount = useMemo( () => props.ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length, [props.ciphers], ); const ciphersById = useMemo(() => new Map(props.ciphers.map((cipher) => [cipher.id, cipher])), [props.ciphers]); const filteredItems = useMemo(() => { if (!report || filter === 'all') return report?.items || []; if (filter === 'exposed') return report.items.filter((item) => (item.exposedCount || 0) > 0); if (filter === 'reused') return report.items.filter((item) => item.reusedCount > 1); return report.items.filter((item) => item.weak); }, [filter, report]); const allPasswordsVisible = !!report?.items.length && report.items.every((item) => revealedPasswordIds.has(item.cipherId)); const togglePasswordVisibility = (cipherId: string) => { setRevealedPasswordIds((current) => { const next = new Set(current); if (next.has(cipherId)) next.delete(cipherId); else next.add(cipherId); return next; }); }; const toggleAllPasswordVisibility = () => { if (!report) return; setRevealedPasswordIds(allPasswordsVisible ? new Set() : new Set(report.items.map((item) => item.cipherId))); }; const scan = () => { setRevealedPasswordIds(new Set()); setFilter('all'); startPasswordSecurityScan(fingerprint, props.ciphers); }; return (

{t('txt_password_security')}

{t('txt_password_security_privacy')}

{scannedAt &&

{t('txt_password_security_last_checked', { value: formatCheckedAt(scannedAt) })}

}
{report && }
{!report && !scanning && !props.loading && (
)} {(scanning || report) && (
} tone="danger" label={t('txt_exposed_passwords')} value={report?.exposedCount ?? 0} active={filter === 'exposed'} disabled={!report} onClick={() => setFilter('exposed')} /> } tone="warning" label={t('txt_reused_passwords')} value={report?.reusedCount ?? 0} active={filter === 'reused'} disabled={!report} onClick={() => setFilter('reused')} /> } tone="warning" label={t('txt_weak_passwords')} value={report?.weakCount ?? 0} active={filter === 'weak'} disabled={!report} onClick={() => setFilter('weak')} /> } tone="primary" label={t('txt_passwords_checked')} value={`${scanning ? progress.checked : report?.checkedCount || 0} / ${scanning ? progress.total : report?.eligibleCount || 0}`} active={filter === 'all'} disabled={!report} onClick={() => setFilter('all')} />
)} {scanError &&
{t('txt_password_security_check_failed')}
} {report && (
{report.unavailableCount > 0 && (
{t('txt_password_security_unavailable', { count: report.unavailableCount })}
)} {!report.items.length ? (
{t('txt_no_password_risks')}
) : !filteredItems.length ? (
{t('txt_no_password_risks_in_filter')}
) : (
{filteredItems.map((item) => { const cipher = ciphersById.get(item.cipherId); const name = String(cipher?.decName || cipher?.name || ''); const password = String(cipher?.login?.decPassword || ''); const passwordVisible = revealedPasswordIds.has(item.cipherId); return
{name || t('txt_no_name')}
{item.exposedCount === null && {t('txt_password_security_not_checked')}} {(item.exposedCount || 0) > 0 && {t('txt_password_security_exposed_short', { count: item.exposedCount || 0 })}} {item.weak && {t('txt_password_security_weak_short')}} {item.reusedCount > 1 && {t('txt_password_security_reused_short')}}
{passwordVisible ? password : maskSecret(password)}
{t('txt_password_security_jump')}
; })}
)}
)}
); } function SecurityMetric(props: { icon: preact.ComponentChildren; tone: 'danger' | 'warning' | 'primary'; label: string; value: string | number; active: boolean; disabled: boolean; onClick: () => void }) { return ; }