mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-11-04 18:40:20 +00:00
feat: add Password Security feature with scanning and reporting capabilities
This commit is contained in:
1 parent
dfc98008cb
commit
99b50275a6
22 files changed
+847
-10
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Sparkles, Users } from 'lucide-preact';
|
||||
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldCheck, ShieldUser, SlidersHorizontal, Sparkles, Users } from 'lucide-preact';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||
import { Link } from 'wouter';
|
||||
@@ -55,7 +55,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
const isDomainRulesRoute = props.location === '/settings/domain-rules';
|
||||
const isLogRoute = props.location === '/logs';
|
||||
const isAdmin = isAdminProfile(props.profile);
|
||||
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
|
||||
const vaultActive = props.location === '/vault' || props.location === '/vault/totp' || props.location === '/security/password-health';
|
||||
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
|
||||
const settingsActive = props.location === '/settings' || props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules' || deviceManagementActive;
|
||||
const flatSettingsActive = settingsActive && !deviceManagementActive;
|
||||
@@ -175,6 +175,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<>
|
||||
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
|
||||
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
|
||||
{renderSideLink('/security/password-health', props.location === '/security/password-health', <ShieldCheck size={16} />, t('nav_password_security'))}
|
||||
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
|
||||
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
||||
{renderSideLink('/settings', flatSettingsActive, <SettingsIcon size={16} />, t('txt_settings'))}
|
||||
@@ -196,6 +197,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<>
|
||||
{renderSubLink('/vault', props.location === '/vault', t('nav_vault_items'))}
|
||||
{renderSubLink('/vault/totp', props.location === '/vault/totp', t('txt_verification_code'))}
|
||||
{renderSubLink('/security/password-health', props.location === '/security/password-health', t('nav_password_security'))}
|
||||
</>
|
||||
)}
|
||||
{renderSideLink('/generator', props.location === '/generator', <Sparkles size={16} />, t('nav_generator'))}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { lazy, Suspense } from 'preact/compat';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
import { Link, Route, Switch } from 'wouter';
|
||||
import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldUser } from 'lucide-preact';
|
||||
import { ArrowUpDown, Cloud, FileClock, Globe2, LogOut, Settings as SettingsIcon, Shield, ShieldCheck, ShieldUser } from 'lucide-preact';
|
||||
import type { ImportAttachmentFile, ImportResultSummary } from '@/components/ImportPage';
|
||||
import LoadingState from '@/components/LoadingState';
|
||||
import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSettings, RemoteBackupBrowserResponse } from '@/lib/api/backup';
|
||||
@@ -14,6 +14,7 @@ import type { ExportRequest } from '@/lib/export-formats';
|
||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||
const SendsPage = lazy(() => import('@/components/SendsPage'));
|
||||
const PasswordGeneratorPage = lazy(() => import('@/components/PasswordGeneratorPage'));
|
||||
const PasswordSecurityPage = lazy(() => import('@/components/PasswordSecurityPage'));
|
||||
const TotpCodesPage = lazy(() => import('@/components/TotpCodesPage'));
|
||||
const SettingsPage = lazy(() => import('@/components/SettingsPage'));
|
||||
const DomainRulesPage = lazy(() => import('@/components/DomainRulesPage'));
|
||||
@@ -208,6 +209,11 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/security/password-health">
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<PasswordSecurityPage ciphers={props.decryptedCiphers} loading={props.ciphersLoading} />
|
||||
</Suspense>
|
||||
</Route>
|
||||
<Route path="/generator">
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<PasswordGeneratorPage />
|
||||
@@ -334,6 +340,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<SettingsIcon size={18} />
|
||||
<span>{t('nav_account_settings')}</span>
|
||||
</Link>
|
||||
<Link href="/security/password-health" className="mobile-settings-link">
|
||||
<ShieldCheck size={18} />
|
||||
<span>{t('nav_password_security')}</span>
|
||||
</Link>
|
||||
<Link href="/settings/security/device-management" className="mobile-settings-link">
|
||||
<Shield size={18} />
|
||||
<span>{t('nav_device_management')}</span>
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
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<PasswordSecurityFilter>('all');
|
||||
const [revealedPasswordIds, setRevealedPasswordIds] = useState<Set<string>>(() => 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 (
|
||||
<section className="password-security-page" aria-label={t('txt_password_security')}>
|
||||
<div className="password-security-intro card">
|
||||
<div className="password-security-intro-icon"><ShieldCheck size={22} /></div>
|
||||
<div>
|
||||
<h2>{t('txt_password_security')}</h2>
|
||||
<p>{t('txt_password_security_privacy')}</p>
|
||||
{scannedAt && <p className="password-security-checked-at">{t('txt_password_security_last_checked', { value: formatCheckedAt(scannedAt) })}</p>}
|
||||
</div>
|
||||
<div className="password-security-intro-actions">
|
||||
{report && <button type="button" className="btn btn-secondary password-security-toggle-all" onClick={toggleAllPasswordVisibility}>
|
||||
{allPasswordsVisible ? <EyeOff size={16} className="btn-icon" /> : <Eye size={16} className="btn-icon" />}
|
||||
{allPasswordsVisible ? t('txt_password_security_hide_all') : t('txt_password_security_show_all')}
|
||||
</button>}
|
||||
<button type="button" className="btn btn-primary password-security-scan" disabled={props.loading || scanning || eligibleCount === 0} onClick={scan}>
|
||||
{scanning ? <RefreshCw size={16} className="btn-icon spin" /> : <ScanSearch size={16} className="btn-icon" />}
|
||||
{scanning ? t('txt_checking_password_security') : report ? t('txt_recheck_password_security') : t('txt_check_password_security')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!report && !scanning && !props.loading && (
|
||||
<div className="password-security-empty card">
|
||||
<ShieldCheck size={26} aria-hidden="true" />
|
||||
<strong>{eligibleCount ? t('txt_password_security_ready') : t('txt_password_security_no_login')}</strong>
|
||||
<span>{eligibleCount ? t('txt_password_security_manual') : t('txt_password_security_no_login_help')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(scanning || report) && (
|
||||
<div className="password-security-summary" aria-live="polite">
|
||||
<SecurityMetric icon={<ShieldAlert size={18} />} tone="danger" label={t('txt_exposed_passwords')} value={report?.exposedCount ?? 0} active={filter === 'exposed'} disabled={!report} onClick={() => setFilter('exposed')} />
|
||||
<SecurityMetric icon={<AlertTriangle size={18} />} tone="warning" label={t('txt_reused_passwords')} value={report?.reusedCount ?? 0} active={filter === 'reused'} disabled={!report} onClick={() => setFilter('reused')} />
|
||||
<SecurityMetric icon={<AlertTriangle size={18} />} tone="warning" label={t('txt_weak_passwords')} value={report?.weakCount ?? 0} active={filter === 'weak'} disabled={!report} onClick={() => setFilter('weak')} />
|
||||
<SecurityMetric icon={<CheckCircle2 size={18} />} 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')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scanError && <div className="password-security-notice warning card" role="alert"><Unplug size={16} />{t('txt_password_security_check_failed')}</div>}
|
||||
|
||||
{report && (
|
||||
<section className="password-security-results card">
|
||||
{report.unavailableCount > 0 && (
|
||||
<div className="password-security-notice warning"><Unplug size={16} />{t('txt_password_security_unavailable', { count: report.unavailableCount })}</div>
|
||||
)}
|
||||
{!report.items.length ? (
|
||||
<div className="password-security-empty compact"><CheckCircle2 size={25} /><strong>{t('txt_no_password_risks')}</strong></div>
|
||||
) : !filteredItems.length ? (
|
||||
<div className="password-security-empty compact"><CheckCircle2 size={25} /><strong>{t('txt_no_password_risks_in_filter')}</strong></div>
|
||||
) : (
|
||||
<div className="password-security-list">
|
||||
{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 <article className="password-security-item" key={item.cipherId}>
|
||||
<div className="password-security-item-main">
|
||||
<div className="password-security-item-header">
|
||||
<strong>{name || t('txt_no_name')}</strong>
|
||||
<div className="password-security-badges">
|
||||
{item.exposedCount === null && <span className="risk-badge muted">{t('txt_password_security_not_checked')}</span>}
|
||||
{(item.exposedCount || 0) > 0 && <span className="risk-badge danger">{t('txt_password_security_exposed_short', { count: item.exposedCount || 0 })}</span>}
|
||||
{item.weak && <span className="risk-badge weak">{t('txt_password_security_weak_short')}</span>}
|
||||
{item.reusedCount > 1 && <span className="risk-badge reused">{t('txt_password_security_reused_short')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<span className="password-security-password">{passwordVisible ? password : maskSecret(password)}</span>
|
||||
</div>
|
||||
<div className="password-security-item-actions">
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => togglePasswordVisibility(item.cipherId)}>
|
||||
{passwordVisible ? <EyeOff size={14} className="btn-icon" /> : <Eye size={14} className="btn-icon" />}
|
||||
{passwordVisible ? t('txt_hide') : t('txt_reveal')}
|
||||
</button>
|
||||
<Link href={`/vault?cipher=${encodeURIComponent(item.cipherId)}`} className="btn btn-secondary small password-security-open">
|
||||
<ExternalLink size={14} className="btn-icon" />{t('txt_password_security_jump')}
|
||||
</Link>
|
||||
</div>
|
||||
</article>;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SecurityMetric(props: { icon: preact.ComponentChildren; tone: 'danger' | 'warning' | 'primary'; label: string; value: string | number; active: boolean; disabled: boolean; onClick: () => void }) {
|
||||
return <button type="button" className={`password-security-metric ${props.tone}`} aria-pressed={props.active} disabled={props.disabled} onClick={props.onClick}><span>{props.icon}</span><div><strong>{props.value}</strong><small>{props.label}</small></div></button>;
|
||||
}
|
||||
@@ -87,6 +87,7 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
||||
const [selectedCipherId, setSelectedCipherId] = useState('');
|
||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||
const pendingFocusCipherIdRef = useRef<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [createMenuOpen, setCreateMenuOpen] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -497,8 +498,59 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
|
||||
}, [sidebarFilter.kind, duplicateMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const focusId = String(new URLSearchParams(window.location.search || '').get('cipher') || '').trim();
|
||||
if (!focusId) return;
|
||||
pendingFocusCipherIdRef.current = focusId;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const focusId = pendingFocusCipherIdRef.current;
|
||||
if (!focusId) return;
|
||||
const cipher = cipherById.get(focusId);
|
||||
if (!cipher) {
|
||||
if (!props.loading && props.ciphers.length > 0) pendingFocusCipherIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextFilter: SidebarFilter = isCipherVisibleInTrash(cipher)
|
||||
? { kind: 'trash' }
|
||||
: isCipherVisibleInArchive(cipher)
|
||||
? { kind: 'archive' }
|
||||
: { kind: 'all' };
|
||||
setSidebarFilter((prev) => (prev.kind === nextFilter.kind ? prev : nextFilter));
|
||||
setSearchInput('');
|
||||
setSearchQuery('');
|
||||
setIsEditing(false);
|
||||
setIsCreating(false);
|
||||
setDraft(null);
|
||||
}, [cipherById, props.ciphers.length, props.loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreating) return;
|
||||
|
||||
const focusId = pendingFocusCipherIdRef.current;
|
||||
if (focusId) {
|
||||
if (!filteredCipherIds.has(focusId)) return;
|
||||
setSelectedCipherId(focusId);
|
||||
setRepromptApprovedCipherId(null);
|
||||
setShowPassword(false);
|
||||
setHiddenFieldVisibleMap({});
|
||||
if (isMobileLayout) setMobilePanel('detail');
|
||||
setMobileSidebarOpen(false);
|
||||
pendingFocusCipherIdRef.current = null;
|
||||
if (typeof window !== 'undefined' && typeof window.history?.replaceState === 'function') {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has('cipher')) {
|
||||
url.searchParams.delete('cipher');
|
||||
const next = `${url.pathname}${url.search}${url.hash}`;
|
||||
window.history.replaceState(null, '', next || '/vault');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filteredCiphers.length) {
|
||||
if (selectedCipherId) setSelectedCipherId('');
|
||||
return;
|
||||
@@ -506,7 +558,7 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
if (!selectedCipherId || !filteredCipherIds.has(selectedCipherId)) {
|
||||
setSelectedCipherId(filteredCiphers[0].id);
|
||||
}
|
||||
}, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating]);
|
||||
}, [filteredCiphers, filteredCipherIds, selectedCipherId, isCreating, isMobileLayout]);
|
||||
|
||||
const selectedCipher = useMemo(() => cipherById.get(selectedCipherId) || null, [cipherById, selectedCipherId]);
|
||||
const virtualRange = useMemo(() => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import { AlertTriangle, Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RefreshCw, RotateCcw, ShieldCheck, ShieldAlert, Trash2, X } from 'lucide-preact';
|
||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||
import type { TotpCodeResult } from '@/lib/crypto';
|
||||
import { checkPasswordLeaked, type PasswordBreachResult } from '@/lib/password-security';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import {
|
||||
@@ -21,6 +22,10 @@ import {
|
||||
toBooleanFieldValue,
|
||||
} from '@/components/vault/vault-page-helpers';
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return !!error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError';
|
||||
}
|
||||
|
||||
interface VaultDetailViewProps {
|
||||
selectedCipher: Cipher;
|
||||
repromptApprovedCipherId: string | null;
|
||||
@@ -90,6 +95,9 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
const selectedAttachments = Array.isArray(props.selectedCipher.attachments) ? props.selectedCipher.attachments : [];
|
||||
const [showSshPrivateKey, setShowSshPrivateKey] = useState(false);
|
||||
const [passwordHistoryOpen, setPasswordHistoryOpen] = useState(false);
|
||||
const [breachResult, setBreachResult] = useState<PasswordBreachResult | null>(null);
|
||||
const [checkingBreach, setCheckingBreach] = useState(false);
|
||||
const breachControllerRef = useRef<AbortController | null>(null);
|
||||
const isArchived = !!(props.selectedCipher.archivedDate || (props.selectedCipher as { archivedAt?: string | null }).archivedAt);
|
||||
const isDeleted = isCipherDeleted(props.selectedCipher);
|
||||
const passwordHistoryEntries = useMemo(
|
||||
@@ -103,9 +111,39 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
[props.selectedCipher.passwordHistory]
|
||||
);
|
||||
useEffect(() => {
|
||||
breachControllerRef.current?.abort();
|
||||
breachControllerRef.current = null;
|
||||
setShowSshPrivateKey(false);
|
||||
setPasswordHistoryOpen(false);
|
||||
}, [props.selectedCipher.id]);
|
||||
setBreachResult(null);
|
||||
setCheckingBreach(false);
|
||||
return () => {
|
||||
breachControllerRef.current?.abort();
|
||||
breachControllerRef.current = null;
|
||||
};
|
||||
}, [props.selectedCipher.id, props.selectedCipher.login?.decPassword]);
|
||||
const checkBreach = async () => {
|
||||
const password = String(props.selectedCipher.login?.decPassword || '');
|
||||
if (!password) return;
|
||||
breachControllerRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
breachControllerRef.current = controller;
|
||||
setCheckingBreach(true);
|
||||
setBreachResult(null);
|
||||
try {
|
||||
const result = await checkPasswordLeaked(password, fetch, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
setBreachResult(result);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || isAbortError(error)) return;
|
||||
setBreachResult({ count: null, available: false });
|
||||
} finally {
|
||||
if (breachControllerRef.current === controller) {
|
||||
breachControllerRef.current = null;
|
||||
setCheckingBreach(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
const formatDownloadLabel = (attachmentId: string) => {
|
||||
const downloadKey = `${props.selectedCipher.id}:${attachmentId}`;
|
||||
if (props.downloadingAttachmentKey !== downloadKey) return t('txt_download');
|
||||
@@ -172,8 +210,18 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => copyToClipboard(props.selectedCipher.login?.decPassword || '')}>
|
||||
<Clipboard size={14} className="btn-icon" /> {t('txt_copy')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary small" disabled={checkingBreach || !props.selectedCipher.login?.decPassword} onClick={() => void checkBreach()}>
|
||||
{checkingBreach ? <RefreshCw size={14} className="btn-icon spin" /> : <ShieldCheck size={14} className="btn-icon" />}
|
||||
{checkingBreach ? t('txt_checking_password_security') : t('txt_check_password_breach')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{breachResult && (
|
||||
<div className={`password-breach-inline ${breachResult.available ? (breachResult.count ? 'danger' : 'safe') : 'warning'}`} role="status">
|
||||
{breachResult.available ? (breachResult.count ? <ShieldAlert size={15} /> : <ShieldCheck size={15} />) : <AlertTriangle size={15} />}
|
||||
<span>{breachResult.available ? (breachResult.count ? t('txt_password_exposed_count', { count: breachResult.count }) : t('txt_password_not_found_in_breaches')) : t('txt_password_security_check_failed')}</span>
|
||||
</div>
|
||||
)}
|
||||
{!!props.selectedCipher.login.decTotp && (
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">{t('txt_totp')}</span>
|
||||
|
||||
@@ -17,11 +17,13 @@ import {
|
||||
LayoutGrid,
|
||||
Pencil,
|
||||
ShieldUser,
|
||||
ShieldCheck,
|
||||
Star,
|
||||
StickyNote,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-preact';
|
||||
import { Link } from 'wouter';
|
||||
import type { Folder } from '@/lib/types';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { getFolderSortOptions, type SidebarFilter, type VaultSortMode } from '@/components/vault/vault-page-helpers';
|
||||
@@ -95,6 +97,9 @@ export default function VaultSidebar(props: VaultSidebarProps) {
|
||||
</div>
|
||||
)}
|
||||
<div className="sidebar-block">
|
||||
<Link href="/security/password-health" className="tree-btn">
|
||||
<ShieldCheck size={14} className="tree-icon" /> <span className="tree-label">{t('nav_password_security')}</span>
|
||||
</Link>
|
||||
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'all' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'all' })}>
|
||||
<LayoutGrid size={14} className="tree-icon" /> <span className="tree-label">{t('txt_all_items')}</span>
|
||||
</button>
|
||||
|
||||
Reference in new issue
Block a user