mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat(vault): add password exposure check and related UI enhancements
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import { calcTotpNow } from '@/lib/crypto';
|
||||
import { checkCipherPasswordsExposed } from '@/lib/password-breach';
|
||||
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
|
||||
import {
|
||||
ArrowUpDown,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
ShieldAlert,
|
||||
ShieldUser,
|
||||
Star,
|
||||
StarOff,
|
||||
@@ -48,7 +50,7 @@ interface VaultPageProps {
|
||||
onBulkDelete: (ids: string[]) => Promise<void>;
|
||||
onBulkMove: (ids: string[], folderId: string | null) => Promise<void>;
|
||||
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
onCreateFolder: (name: string) => Promise<void>;
|
||||
onDeleteFolder: (folderId: string) => Promise<void>;
|
||||
onDownloadAttachment: (cipher: Cipher, attachmentId: string) => Promise<void>;
|
||||
@@ -59,6 +61,7 @@ type VaultSortMode = 'edited' | 'created' | 'name';
|
||||
type SidebarFilter =
|
||||
| { kind: 'all' }
|
||||
| { kind: 'favorite' }
|
||||
| { kind: 'exposed' }
|
||||
| { kind: 'trash' }
|
||||
| { kind: 'type'; value: TypeFilter }
|
||||
| { kind: 'folder'; folderId: string | null };
|
||||
@@ -77,6 +80,8 @@ const CREATE_TYPE_OPTIONS: TypeOption[] = [
|
||||
];
|
||||
|
||||
const VAULT_SORT_STORAGE_KEY = 'nodewarden.vault.sort.v1';
|
||||
const VAULT_EXPOSED_IGNORED_STORAGE_KEY = 'nodewarden.vault.exposed-ignored.v1';
|
||||
const VAULT_EXPOSED_SIGNATURE_STORAGE_KEY = 'nodewarden.vault.exposed-signature.v1';
|
||||
const MOBILE_LAYOUT_QUERY = '(max-width: 900px)';
|
||||
const VAULT_SORT_OPTIONS: Array<{ value: VaultSortMode; label: string }> = [
|
||||
{ value: 'edited', label: t('txt_sort_last_edited') },
|
||||
@@ -366,12 +371,43 @@ function openUri(raw: string): void {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
async function computePasswordSignature(ciphers: Cipher[]): Promise<string> {
|
||||
const parts = ciphers
|
||||
.filter((cipher) => Number(cipher.type || 1) === 1)
|
||||
.map((cipher) => `${String(cipher.id || '').trim()}\u0000${String(cipher.login?.decPassword || '')}`)
|
||||
.sort();
|
||||
const bytes = new TextEncoder().encode(parts.join('\u0001'));
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function countVisibleExposed(results: Record<string, boolean>, ignoredMap: Record<string, boolean>): number {
|
||||
let count = 0;
|
||||
for (const [cipherId, exposed] of Object.entries(results)) {
|
||||
if (exposed && !ignoredMap[cipherId]) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function readIgnoredExposedMap(): Record<string, boolean> {
|
||||
try {
|
||||
const raw = localStorage.getItem(VAULT_EXPOSED_IGNORED_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as Record<string, boolean>;
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export default function VaultPage(props: VaultPageProps) {
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchComposing, setSearchComposing] = useState(false);
|
||||
const [sortMode, setSortMode] = useState<VaultSortMode>('edited');
|
||||
const [sortMenuOpen, setSortMenuOpen] = useState(false);
|
||||
const [exposedStatusMap, setExposedStatusMap] = useState<Record<string, boolean>>({});
|
||||
const [ignoredExposedMap, setIgnoredExposedMap] = useState<Record<string, boolean>>(() => readIgnoredExposedMap());
|
||||
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
|
||||
const [selectedCipherId, setSelectedCipherId] = useState('');
|
||||
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
|
||||
@@ -408,6 +444,11 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const sshSeedTicketRef = useRef(0);
|
||||
const sshFingerprintTicketRef = useRef(0);
|
||||
const hasCompletedAutoExposureCheckRef = useRef(false);
|
||||
|
||||
function isVisibleExposed(cipherId: string): boolean {
|
||||
return !!exposedStatusMap[cipherId] && !ignoredExposedMap[cipherId];
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
|
||||
@@ -457,6 +498,59 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
}
|
||||
}, [sortMode]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(VAULT_EXPOSED_IGNORED_STORAGE_KEY, JSON.stringify(ignoredExposedMap));
|
||||
} catch {
|
||||
// ignore storage write failures
|
||||
}
|
||||
}, [ignoredExposedMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.loading) return;
|
||||
|
||||
const loginCiphers = props.ciphers.filter(
|
||||
(cipher) => Number(cipher.type || 1) === 1 && !!String(cipher.login?.decPassword || '').trim()
|
||||
);
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const [signature, results] = await Promise.all([
|
||||
computePasswordSignature(loginCiphers),
|
||||
checkCipherPasswordsExposed(loginCiphers),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
|
||||
setExposedStatusMap(results);
|
||||
|
||||
const previousSignature =
|
||||
typeof localStorage !== 'undefined'
|
||||
? String(localStorage.getItem(VAULT_EXPOSED_SIGNATURE_STORAGE_KEY) || '').trim()
|
||||
: '';
|
||||
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(VAULT_EXPOSED_SIGNATURE_STORAGE_KEY, signature);
|
||||
}
|
||||
|
||||
if (hasCompletedAutoExposureCheckRef.current && previousSignature && previousSignature !== signature) {
|
||||
const count = countVisibleExposed(results, ignoredExposedMap);
|
||||
if (count > 0) {
|
||||
props.onNotify('warning', t('txt_exposed_password_check_complete_count', { count }));
|
||||
}
|
||||
}
|
||||
hasCompletedAutoExposureCheckRef.current = true;
|
||||
} catch {
|
||||
// Keep exposed-password checks silent in the background.
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [props.ciphers, props.loading]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPointerDown = (event: Event) => {
|
||||
if (!createMenuOpen) return;
|
||||
@@ -533,6 +627,7 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
} else {
|
||||
if (isDeleted) return false;
|
||||
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
|
||||
if (sidebarFilter.kind === 'exposed' && !isVisibleExposed(cipher.id)) return false;
|
||||
if (sidebarFilter.kind === 'type' && cipherTypeKey(Number(cipher.type || 1)) !== sidebarFilter.value) return false;
|
||||
if (sidebarFilter.kind === 'folder') {
|
||||
if (sidebarFilter.folderId === null) {
|
||||
@@ -568,7 +663,7 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
});
|
||||
|
||||
return next;
|
||||
}, [props.ciphers, sidebarFilter, searchQuery, sortMode]);
|
||||
}, [props.ciphers, sidebarFilter, searchQuery, sortMode, exposedStatusMap, ignoredExposedMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreating) return;
|
||||
@@ -585,6 +680,8 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
() => props.ciphers.find((x) => x.id === selectedCipherId) || null,
|
||||
[props.ciphers, selectedCipherId]
|
||||
);
|
||||
const selectedCipherExposed = !!(selectedCipher && exposedStatusMap[selectedCipher.id]);
|
||||
const selectedCipherIgnored = !!(selectedCipher && ignoredExposedMap[selectedCipher.id]);
|
||||
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
|
||||
const selectedAttachments = useMemo(
|
||||
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
|
||||
@@ -788,11 +885,26 @@ function folderName(id: string | null | undefined): string {
|
||||
if (isCreating) {
|
||||
await props.onCreate(nextDraft, attachmentQueue);
|
||||
} else if (selectedCipher) {
|
||||
const passwordChanged =
|
||||
nextDraft.type === 1 &&
|
||||
String(nextDraft.loginPassword || '') !== String(selectedCipher.login?.decPassword || '');
|
||||
const removeAttachmentIds = Object.keys(removedAttachmentIds).filter((id) => !!removedAttachmentIds[id]);
|
||||
await props.onUpdate(selectedCipher, nextDraft, {
|
||||
addFiles: attachmentQueue,
|
||||
removeAttachmentIds,
|
||||
});
|
||||
if (passwordChanged) {
|
||||
setExposedStatusMap((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[selectedCipher.id];
|
||||
return next;
|
||||
});
|
||||
setIgnoredExposedMap((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[selectedCipher.id];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
setIsCreating(false);
|
||||
setIsEditing(false);
|
||||
@@ -859,6 +971,15 @@ function folderName(id: string | null | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleIgnoreExposed(cipherId: string): void {
|
||||
setIgnoredExposedMap((prev) => {
|
||||
const next = { ...prev };
|
||||
if (next[cipherId]) delete next[cipherId];
|
||||
else next[cipherId] = true;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyReprompt(): Promise<void> {
|
||||
if (!selectedCipher) return;
|
||||
if (!repromptPassword) {
|
||||
@@ -927,6 +1048,9 @@ function folderName(id: string | null | undefined): string {
|
||||
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'favorite' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'favorite' })}>
|
||||
<Star size={14} className="tree-icon" /> <span className="tree-label">{t('txt_favorites')}</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'exposed' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'exposed' })}>
|
||||
<ShieldAlert size={14} className="tree-icon" /> <span className="tree-label">{t('txt_exposed_passwords')}</span>
|
||||
</button>
|
||||
<button type="button" className={`tree-btn ${sidebarFilter.kind === 'trash' ? 'active' : ''}`} onClick={() => setSidebarFilter({ kind: 'trash' })}>
|
||||
<Trash2 size={14} className="tree-icon" /> <span className="tree-label">{t('txt_trash')}</span>
|
||||
</button>
|
||||
@@ -1126,7 +1250,10 @@ function folderName(id: string | null | undefined): string {
|
||||
<VaultListIcon cipher={cipher} />
|
||||
</div>
|
||||
<div className="list-text">
|
||||
<span className="list-title" title={cipher.decName || t('txt_no_name')}>{cipher.decName || t('txt_no_name')}</span>
|
||||
<span className="list-title" title={cipher.decName || t('txt_no_name')}>
|
||||
<span className="list-title-text">{cipher.decName || t('txt_no_name')}</span>
|
||||
{isVisibleExposed(cipher.id) ? <span className="list-badge danger">{t('txt_exposed_short')}</span> : null}
|
||||
</span>
|
||||
<span className="list-sub" title={listSubtitle(cipher)}>{listSubtitle(cipher)}</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -1551,6 +1678,25 @@ function folderName(id: string | null | undefined): string {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{selectedCipherExposed && (
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">{t('txt_exposed_passwords')}</span>
|
||||
<div className="kv-main">
|
||||
<strong className="exposed-status danger">
|
||||
{selectedCipherIgnored ? t('txt_exposed_ignored') : t('txt_exposed')}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="kv-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
onClick={() => toggleIgnoreExposed(selectedCipher.id)}
|
||||
>
|
||||
<X size={14} className="btn-icon" /> {selectedCipherIgnored ? t('txt_unignore') : t('txt_ignore')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!selectedCipher.login.decTotp && (
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">{t('txt_totp')}</span>
|
||||
|
||||
Reference in New Issue
Block a user