mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-04 22:40:11 +00:00
Compare commits
3
Commits
b472121f43
...
fb376797d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb376797d2 | ||
|
|
99b50275a6 | ||
|
|
dfc98008cb |
+1
-1
@@ -9,7 +9,7 @@
|
||||
script-src 'self' 'unsafe-inline';
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' data:;
|
||||
connect-src 'self';
|
||||
connect-src 'self' https://api.pwnedpasswords.com;
|
||||
font-src 'self';
|
||||
form-action 'self';
|
||||
base-uri 'self';
|
||||
|
||||
@@ -68,6 +68,7 @@ import { t } from '@/lib/i18n';
|
||||
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
||||
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
|
||||
import { clearPasswordSecurityCache } from '@/lib/password-security-cache';
|
||||
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
|
||||
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
|
||||
import {
|
||||
@@ -111,6 +112,8 @@ const APP_ROUTE_PATHS = [
|
||||
'/',
|
||||
'/vault',
|
||||
'/vault/totp',
|
||||
'/security/password-health',
|
||||
'/generator',
|
||||
'/sends',
|
||||
'/admin',
|
||||
'/logs',
|
||||
@@ -385,6 +388,10 @@ export default function App() {
|
||||
}
|
||||
}, [phase, profile, session]);
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== 'app') clearPasswordSecurityCache();
|
||||
}, [phase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
|
||||
@@ -871,6 +878,7 @@ export default function App() {
|
||||
setDecryptedFolders([]);
|
||||
setDecryptedCiphers([]);
|
||||
setDecryptedSends([]);
|
||||
clearPasswordSecurityCache();
|
||||
setUnlockPassword('');
|
||||
setPendingTotp(null);
|
||||
setPendingTotpMode(null);
|
||||
@@ -892,6 +900,7 @@ export default function App() {
|
||||
setSession(null);
|
||||
clearProfileSnapshot();
|
||||
clearOfflineUnlockRecord();
|
||||
clearPasswordSecurityCache();
|
||||
setProfile(null);
|
||||
setUnlockPreparing(false);
|
||||
setPendingTotp(null);
|
||||
@@ -1901,13 +1910,17 @@ export default function App() {
|
||||
const mobilePrimaryRoute =
|
||||
location === '/sends'
|
||||
? '/sends'
|
||||
: location === '/generator'
|
||||
? '/generator'
|
||||
: location === '/vault/totp'
|
||||
? '/vault/totp'
|
||||
: location === '/vault'
|
||||
? '/vault'
|
||||
: '/settings';
|
||||
const currentPageTitle = (() => {
|
||||
if (location === '/security/password-health') return t('txt_password_security');
|
||||
if (location === '/vault/totp') return t('txt_verification_code');
|
||||
if (location === '/generator') return t('txt_password_generator');
|
||||
if (location === '/sends') return t('nav_sends');
|
||||
if (location === '/admin') return t('nav_admin_panel');
|
||||
if (location === '/logs') return t('nav_log_center');
|
||||
|
||||
@@ -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, 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,8 @@ 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'))}
|
||||
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
|
||||
@@ -195,8 +197,10 @@ 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'))}
|
||||
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
|
||||
{renderNavGroup(
|
||||
'settings',
|
||||
@@ -327,6 +331,10 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
|
||||
<Clock3 size={18} />
|
||||
<span>{t('txt_verification_code')}</span>
|
||||
</Link>
|
||||
<Link href="/generator" className={`mobile-tab ${props.mobilePrimaryRoute === '/generator' ? 'active' : ''}`}>
|
||||
<Sparkles size={18} />
|
||||
<span>{t('nav_generator')}</span>
|
||||
</Link>
|
||||
<Link href="/sends" className={`mobile-tab ${props.mobilePrimaryRoute === '/sends' ? 'active' : ''}`}>
|
||||
<SendIcon size={18} />
|
||||
<span>{t('nav_sends')}</span>
|
||||
|
||||
@@ -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';
|
||||
@@ -13,6 +13,8 @@ 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'));
|
||||
@@ -207,6 +209,16 @@ 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 />
|
||||
</Suspense>
|
||||
</Route>
|
||||
<Route path="/sends">
|
||||
<Suspense fallback={<RouteContentFallback />}>
|
||||
<SendsPage
|
||||
@@ -328,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,247 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { Check, Copy, Minus, Plus, RefreshCw, ShieldCheck } from 'lucide-preact';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { EFFLongWordList } from '@/lib/eff-word-list';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
type GeneratorMode = 'password' | 'passphrase';
|
||||
|
||||
interface PasswordOptions {
|
||||
length: number;
|
||||
uppercase: boolean;
|
||||
lowercase: boolean;
|
||||
numbers: boolean;
|
||||
special: boolean;
|
||||
minNumbers: number;
|
||||
minSpecial: number;
|
||||
avoidAmbiguous: boolean;
|
||||
}
|
||||
|
||||
interface PassphraseOptions {
|
||||
words: number;
|
||||
separator: string;
|
||||
capitalize: boolean;
|
||||
includeNumber: boolean;
|
||||
}
|
||||
|
||||
const SETTINGS_KEY = 'nodewarden.passwordGenerator.settings.v1';
|
||||
const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const DIGITS = '0123456789';
|
||||
const SPECIAL = '!@#$%^&*';
|
||||
const AMBIGUOUS = new Set(['I', 'L', 'O', 'l', 'o', '0', '1']);
|
||||
|
||||
const defaultPasswordOptions: PasswordOptions = {
|
||||
length: 14,
|
||||
uppercase: true,
|
||||
lowercase: true,
|
||||
numbers: true,
|
||||
special: false,
|
||||
minNumbers: 1,
|
||||
minSpecial: 1,
|
||||
avoidAmbiguous: false,
|
||||
};
|
||||
|
||||
const defaultPassphraseOptions: PassphraseOptions = {
|
||||
words: 6,
|
||||
separator: '-',
|
||||
capitalize: false,
|
||||
includeNumber: false,
|
||||
};
|
||||
|
||||
function clamp(value: unknown, minimum: number, maximum: number, fallback: number): number {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.min(maximum, Math.max(minimum, Math.round(parsed))) : fallback;
|
||||
}
|
||||
|
||||
function readSettings(): { mode: GeneratorMode; password: PasswordOptions; passphrase: PassphraseOptions } {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}') as Partial<{ mode: GeneratorMode; password: Partial<PasswordOptions>; passphrase: Partial<PassphraseOptions> }>;
|
||||
return {
|
||||
mode: stored.mode === 'passphrase' ? 'passphrase' : 'password',
|
||||
password: {
|
||||
...defaultPasswordOptions,
|
||||
...stored.password,
|
||||
length: clamp(stored.password?.length, 5, 128, defaultPasswordOptions.length),
|
||||
minNumbers: clamp(stored.password?.minNumbers, 0, 9, defaultPasswordOptions.minNumbers),
|
||||
minSpecial: clamp(stored.password?.minSpecial, 0, 9, defaultPasswordOptions.minSpecial),
|
||||
},
|
||||
passphrase: {
|
||||
...defaultPassphraseOptions,
|
||||
...stored.passphrase,
|
||||
words: clamp(stored.passphrase?.words, 3, 20, defaultPassphraseOptions.words),
|
||||
separator: String(stored.passphrase?.separator ?? defaultPassphraseOptions.separator).slice(0, 1),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { mode: 'password', password: defaultPasswordOptions, passphrase: defaultPassphraseOptions };
|
||||
}
|
||||
}
|
||||
|
||||
function randomIndex(length: number): number {
|
||||
const range = 0x1_0000_0000;
|
||||
const upperBound = Math.floor(range / length) * length;
|
||||
const buffer = new Uint32Array(1);
|
||||
do crypto.getRandomValues(buffer); while (buffer[0] >= upperBound);
|
||||
return buffer[0] % length;
|
||||
}
|
||||
|
||||
function pick(characters: string): string {
|
||||
return characters[randomIndex(characters.length)];
|
||||
}
|
||||
|
||||
function shuffle(value: string[]): string[] {
|
||||
for (let index = value.length - 1; index > 0; index -= 1) {
|
||||
const next = randomIndex(index + 1);
|
||||
[value[index], value[next]] = [value[next], value[index]];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function filtered(characters: string, avoidAmbiguous: boolean): string {
|
||||
return avoidAmbiguous ? characters.split('').filter((character) => !AMBIGUOUS.has(character)).join('') : characters;
|
||||
}
|
||||
|
||||
function generatePassword(options: PasswordOptions): string {
|
||||
const sets: Array<{ chars: string; minimum: number }> = [];
|
||||
if (options.uppercase) sets.push({ chars: filtered(UPPERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||
if (options.lowercase) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||
if (options.numbers) sets.push({ chars: filtered(DIGITS, options.avoidAmbiguous), minimum: options.minNumbers });
|
||||
if (options.special) sets.push({ chars: SPECIAL, minimum: options.minSpecial });
|
||||
if (!sets.length) sets.push({ chars: filtered(LOWERCASE, options.avoidAmbiguous), minimum: 1 });
|
||||
|
||||
const minimumLength = sets.reduce((total, set) => total + set.minimum, 0);
|
||||
const length = Math.max(options.length, minimumLength, 5);
|
||||
const allCharacters = sets.map((set) => set.chars).join('');
|
||||
const characters = sets.flatMap((set) => Array.from({ length: set.minimum }, () => pick(set.chars)));
|
||||
while (characters.length < length) characters.push(pick(allCharacters));
|
||||
return shuffle(characters).join('');
|
||||
}
|
||||
|
||||
function generatePassphrase(options: PassphraseOptions): string {
|
||||
const words = Array.from({ length: options.words }, () => EFFLongWordList[randomIndex(EFFLongWordList.length)]);
|
||||
if (options.capitalize) {
|
||||
for (let index = 0; index < words.length; index += 1) words[index] = words[index][0].toUpperCase() + words[index].slice(1);
|
||||
}
|
||||
if (options.includeNumber) words[randomIndex(words.length)] += String(randomIndex(10));
|
||||
return words.join(options.separator);
|
||||
}
|
||||
|
||||
function strengthLabel(mode: GeneratorMode, value: string): { label: string; score: number } {
|
||||
const score = mode === 'password' ? Math.min(4, Math.max(1, Math.floor(value.length / 5))) : Math.min(4, Math.max(1, Math.floor(value.split(/[-_. ]/).filter(Boolean).length / 2)));
|
||||
return { score, label: t(['txt_password_strength_weak', 'txt_password_strength_fair', 'txt_password_strength_good', 'txt_password_strength_strong'][score - 1]) };
|
||||
}
|
||||
|
||||
export default function PasswordGeneratorPage() {
|
||||
const initial = useMemo(readSettings, []);
|
||||
const [mode, setMode] = useState<GeneratorMode>(initial.mode);
|
||||
const [passwordOptions, setPasswordOptions] = useState<PasswordOptions>(initial.password);
|
||||
const [passphraseOptions, setPassphraseOptions] = useState<PassphraseOptions>(initial.passphrase);
|
||||
const [seed, setSeed] = useState(0);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const generated = useMemo(
|
||||
() => (mode === 'password' ? generatePassword(passwordOptions) : generatePassphrase(passphraseOptions)),
|
||||
[mode, passwordOptions, passphraseOptions, seed]
|
||||
);
|
||||
const strength = useMemo(() => strengthLabel(mode, generated), [generated, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify({ mode, password: passwordOptions, passphrase: passphraseOptions }));
|
||||
} catch {
|
||||
// The generator remains fully usable when browser storage is unavailable.
|
||||
}
|
||||
}, [mode, passwordOptions, passphraseOptions]);
|
||||
|
||||
const regenerate = () => {
|
||||
setCopied(false);
|
||||
setSeed((value) => value + 1);
|
||||
};
|
||||
|
||||
const copy = async () => {
|
||||
await copyTextToClipboard(generated, { onSuccess: () => setCopied(true), onError: () => setCopied(false) });
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
};
|
||||
|
||||
const changePasswordOption = <K extends keyof PasswordOptions>(key: K, value: PasswordOptions[K]) => {
|
||||
setPasswordOptions((current) => ({ ...current, [key]: value }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const changePassphraseOption = <K extends keyof PassphraseOptions>(key: K, value: PassphraseOptions[K]) => {
|
||||
setPassphraseOptions((current) => ({ ...current, [key]: value }));
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="generator-page" aria-label={t('txt_password_generator')}>
|
||||
<div className="generator-layout">
|
||||
<section className="generator-output-card" aria-live="polite">
|
||||
<div className="settings-category-tabs" role="tablist" aria-label={t('txt_generator_type')}>
|
||||
<button type="button" role="tab" aria-selected={mode === 'password'} className={`settings-category-tab ${mode === 'password' ? 'active' : ''}`} onClick={() => setMode('password')}>{t('txt_password')}</button>
|
||||
<button type="button" role="tab" aria-selected={mode === 'passphrase'} className={`settings-category-tab ${mode === 'passphrase' ? 'active' : ''}`} onClick={() => setMode('passphrase')}>{t('txt_passphrase')}</button>
|
||||
</div>
|
||||
<output className="generator-value" aria-label={t('txt_generated_password')}>{generated}</output>
|
||||
<div className="generator-strength-row">
|
||||
<div className="generator-strength" aria-label={`${t('txt_password_strength')}: ${strength.label}`}>
|
||||
{[1, 2, 3, 4].map((level) => <span key={level} className={level <= strength.score ? `active level-${strength.score}` : ''} />)}
|
||||
</div>
|
||||
<span><ShieldCheck size={15} /> {strength.label}</span>
|
||||
</div>
|
||||
<div className="actions generator-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={regenerate}><RefreshCw size={16} className="btn-icon" />{t('txt_regenerate')}</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void copy()}><Copy size={16} className="btn-icon" />{copied ? t('txt_copied') : t('txt_copy')}</button>
|
||||
</div>
|
||||
<p className="generator-security-note"><Check size={15} />{t('txt_generator_security_note')}</p>
|
||||
</section>
|
||||
|
||||
<section className="generator-options-card" aria-labelledby="generator-options-title">
|
||||
<h2 id="generator-options-title">{t('txt_options')}</h2>
|
||||
{mode === 'password' ? (
|
||||
<>
|
||||
<GeneratorNumberStepper id="length" label={t('txt_generator_length')} value={passwordOptions.length} minimum={5} maximum={128} fallback={14} onChange={(value) => changePasswordOption('length', value)} />
|
||||
<fieldset className="generator-option-group"><legend>{t('txt_generator_character_types')}</legend>
|
||||
<GeneratorToggle checked={passwordOptions.uppercase} onChange={(checked) => changePasswordOption('uppercase', checked)} label={t('txt_generator_uppercase')} />
|
||||
<GeneratorToggle checked={passwordOptions.lowercase} onChange={(checked) => changePasswordOption('lowercase', checked)} label={t('txt_generator_lowercase')} />
|
||||
<GeneratorToggle checked={passwordOptions.numbers} onChange={(checked) => changePasswordOption('numbers', checked)} label={t('txt_generator_numbers')} />
|
||||
{passwordOptions.numbers && <GeneratorNumberStepper id="min-numbers" compact label={t('txt_generator_minimum')} value={passwordOptions.minNumbers} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minNumbers', value)} />}
|
||||
<GeneratorToggle checked={passwordOptions.special} onChange={(checked) => changePasswordOption('special', checked)} label={t('txt_generator_special')} />
|
||||
{passwordOptions.special && <GeneratorNumberStepper id="min-special" compact label={t('txt_generator_minimum')} value={passwordOptions.minSpecial} minimum={0} maximum={9} fallback={1} onChange={(value) => changePasswordOption('minSpecial', value)} />}
|
||||
</fieldset>
|
||||
<GeneratorToggle checked={passwordOptions.avoidAmbiguous} onChange={(checked) => changePasswordOption('avoidAmbiguous', checked)} label={t('txt_generator_avoid_ambiguous')} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GeneratorNumberStepper id="words" label={t('txt_generator_words')} value={passphraseOptions.words} minimum={3} maximum={20} fallback={6} onChange={(value) => changePassphraseOption('words', value)} />
|
||||
<label className="generator-number-field" htmlFor="generator-separator"><span>{t('txt_generator_separator')}</span><input id="generator-separator" className="input" type="text" maxLength={1} value={passphraseOptions.separator} onInput={(event) => changePassphraseOption('separator', event.currentTarget.value.slice(0, 1))} /></label>
|
||||
<div className="generator-option-group">
|
||||
<GeneratorToggle checked={passphraseOptions.capitalize} onChange={(checked) => changePassphraseOption('capitalize', checked)} label={t('txt_generator_capitalize')} />
|
||||
<GeneratorToggle checked={passphraseOptions.includeNumber} onChange={(checked) => changePassphraseOption('includeNumber', checked)} label={t('txt_generator_include_number')} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneratorToggle(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) {
|
||||
return <label className="generator-toggle"><input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.currentTarget.checked)} /><span aria-hidden="true" /><strong>{props.label}</strong></label>;
|
||||
}
|
||||
|
||||
function GeneratorNumberStepper(props: { id: string; label: string; value: number; minimum: number; maximum: number; fallback: number; compact?: boolean; onChange: (value: number) => void }) {
|
||||
const id = `generator-stepper-${props.id}`;
|
||||
const setValue = (value: number) => props.onChange(clamp(value, props.minimum, props.maximum, props.fallback));
|
||||
return (
|
||||
<div className={`generator-number-field ${props.compact ? 'compact' : ''}`}>
|
||||
<label htmlFor={id}>{props.label}</label>
|
||||
<div className="generator-stepper">
|
||||
<button type="button" aria-label={`${props.label} -`} disabled={props.value <= props.minimum} onClick={() => setValue(props.value - 1)}><Minus size={15} /></button>
|
||||
<input id={id} className="input" type="text" inputMode="numeric" pattern="[0-9]*" value={props.value} onInput={(event) => setValue(Number(event.currentTarget.value))} />
|
||||
<button type="button" aria-label={`${props.label} +`} disabled={props.value >= props.maximum} onClick={() => setValue(props.value + 1)}><Plus size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ const de: Record<string, string> = {
|
||||
"nav_my_vault": "Mein Tresor",
|
||||
"nav_vault_items": "Tresor",
|
||||
"nav_sends": "Sendungen",
|
||||
"nav_generator": "Passwortwerkzeug", "txt_password_generator": "Passwortgenerator", "txt_password_generator_description": "Erstellen Sie lokal auf diesem Gerät ein starkes, einzigartiges Passwort.", "txt_generator_type": "Generatortyp", "txt_passphrase": "Kennwortsatz", "txt_generated_password": "Generiertes Passwort", "txt_password_strength": "Stärke", "txt_password_strength_weak": "Schwach", "txt_password_strength_fair": "Mittel", "txt_password_strength_good": "Gut", "txt_password_strength_strong": "Stark", "txt_generator_security_note": "Die Erzeugung erfolgt lokal. Ihr Passwort wird nie an den Server gesendet.", "txt_generator_length": "Länge", "txt_generator_character_types": "Zeichentypen", "txt_generator_uppercase": "Großbuchstaben (A-Z)", "txt_generator_lowercase": "Kleinbuchstaben (a-z)", "txt_generator_numbers": "Zahlen (0-9)", "txt_generator_special": "Sonderzeichen (!@#$%^&*)", "txt_generator_minimum": "Mindestanzahl", "txt_generator_avoid_ambiguous": "Verwechselbare Zeichen vermeiden", "txt_generator_words": "Anzahl der Wörter", "txt_generator_separator": "Worttrenner", "txt_generator_capitalize": "Großschreibung", "txt_generator_include_number": "Eine Zahl einfügen",
|
||||
"nav_backup_strategy": "Cloud-Backup",
|
||||
"nav_import_export": "Import und Export",
|
||||
"nav_group_data_backup": "Daten & Backup",
|
||||
@@ -1447,4 +1448,12 @@ const de: Record<string, string> = {
|
||||
"txt_ip_address": "IP-Adresse"
|
||||
};
|
||||
|
||||
Object.assign(de, {
|
||||
"nav_password_security": "Passwortsicherheit", "txt_password_security": "Passwort-Sicherheitsprüfung", "txt_password_security_privacy": "Passwörter werden lokal geprüft. Erst nach dem Start wird nur ein anonymer Hash-Präfix an die Leckdatenbank gesendet.", "txt_check_password_security": "Prüfung starten", "txt_checking_password_security": "Prüfung läuft", "txt_recheck_password_security": "Erneut prüfen", "txt_password_security_ready": "Ihr Tresor ist für eine Sicherheitsprüfung bereit.", "txt_password_security_no_login": "Es gibt keine Login-Passwörter zu prüfen.", "txt_password_security_manual": "Die Prüfung startet nur auf Ihre Anfrage. Ergebnisse bleiben nur auf dieser Seite.", "txt_password_security_no_login_help": "Fügen Sie einen Login-Eintrag mit Passwort hinzu und prüfen Sie ihn anschließend hier.", "txt_exposed_passwords": "Geleakt", "txt_reused_passwords": "Wiederverwendet", "txt_weak_passwords": "Schwach", "txt_passwords_checked": "Geprüft", "txt_password_security_unavailable": "{count} Passwortprüfungen konnten die Leckdatenbank nicht erreichen. Sie werden nicht als sicher markiert.", "txt_password_security_not_checked": "Nicht geprüft", "txt_password_exposed_count": "In {count} Lecks gefunden", "txt_password_reused_count": "{count}-mal verwendet", "txt_weak_password": "Schwaches Passwort", "txt_no_password_risks": "Keine Passwortrisiken gefunden", "txt_open_vault": "Tresor öffnen", "txt_check_password_breach": "Leck prüfen", "txt_password_not_found_in_breaches": "Nicht in der Leckdatenbank gefunden", "txt_password_security_check_failed": "Die Leckprüfung konnte nicht abgeschlossen werden."
|
||||
});
|
||||
|
||||
Object.assign(de, { "txt_password_security_last_checked": "Zuletzt überprüft: {value}" });
|
||||
Object.assign(de, { "txt_no_password_risks_in_filter": "Keine Passwortrisiken in dieser Kategorie" });
|
||||
Object.assign(de, { "txt_password_security_show_all": "Alle anzeigen", "txt_password_security_hide_all": "Alle ausblenden", "txt_password_security_jump": "Öffnen", "txt_password_security_exposed_short": "{count}-mal geleakt", "txt_password_security_weak_short": "Schwaches Passwort", "txt_password_security_reused_short": "Wiederverwendet" });
|
||||
|
||||
export default de;
|
||||
|
||||
@@ -7,6 +7,30 @@ const en: Record<string, string> = {
|
||||
"nav_my_vault": "My Vault",
|
||||
"nav_vault_items": "Vault",
|
||||
"nav_sends": "Sends",
|
||||
"nav_generator": "Generator",
|
||||
"txt_password_generator": "Password Generator",
|
||||
"txt_password_generator_description": "Create a strong, unique password locally on this device.",
|
||||
"txt_generator_type": "Generator type",
|
||||
"txt_passphrase": "Passphrase",
|
||||
"txt_generated_password": "Generated password",
|
||||
"txt_password_strength": "Strength",
|
||||
"txt_password_strength_weak": "Weak",
|
||||
"txt_password_strength_fair": "Fair",
|
||||
"txt_password_strength_good": "Good",
|
||||
"txt_password_strength_strong": "Strong",
|
||||
"txt_generator_security_note": "Generation happens locally. Your generated password is never sent to the server.",
|
||||
"txt_generator_length": "Length",
|
||||
"txt_generator_character_types": "Character types",
|
||||
"txt_generator_uppercase": "Uppercase (A-Z)",
|
||||
"txt_generator_lowercase": "Lowercase (a-z)",
|
||||
"txt_generator_numbers": "Numbers (0-9)",
|
||||
"txt_generator_special": "Special characters (!@#$%^&*)",
|
||||
"txt_generator_minimum": "Minimum",
|
||||
"txt_generator_avoid_ambiguous": "Avoid ambiguous characters",
|
||||
"txt_generator_words": "Number of words",
|
||||
"txt_generator_separator": "Word separator",
|
||||
"txt_generator_capitalize": "Capitalize",
|
||||
"txt_generator_include_number": "Include a number",
|
||||
"nav_backup_strategy": "Cloud Backup",
|
||||
"nav_import_export": "Import & Export",
|
||||
"nav_group_data_backup": "Data & Backup",
|
||||
@@ -1447,4 +1471,39 @@ const en: Record<string, string> = {
|
||||
"txt_ip_address": "IP address"
|
||||
};
|
||||
|
||||
Object.assign(en, {
|
||||
"nav_password_security": "Password Security",
|
||||
"txt_password_security": "Password Security Check",
|
||||
"txt_password_security_privacy": "Passwords are checked locally in your browser. Only an anonymous hash prefix is sent to the breach database.",
|
||||
"txt_check_password_security": "Start check",
|
||||
"txt_checking_password_security": "Checking",
|
||||
"txt_recheck_password_security": "Check again",
|
||||
"txt_password_security_ready": "Your vault is ready for a security check.",
|
||||
"txt_password_security_no_login": "There are no login passwords to check.",
|
||||
"txt_password_security_manual": "The check only starts when you choose it. Results are kept until you refresh or your vault changes.",
|
||||
"txt_password_security_no_login_help": "Add a login item with a password, then return here to check it.",
|
||||
"txt_exposed_passwords": "Exposed",
|
||||
"txt_reused_passwords": "Reused",
|
||||
"txt_weak_passwords": "Weak",
|
||||
"txt_passwords_checked": "Checked",
|
||||
"txt_password_security_last_checked": "Last checked: {value}",
|
||||
"txt_password_security_show_all": "Show all",
|
||||
"txt_password_security_hide_all": "Hide all",
|
||||
"txt_password_security_jump": "Go to item",
|
||||
"txt_password_security_exposed_short": "Exposed {count} times",
|
||||
"txt_password_security_weak_short": "Weak password",
|
||||
"txt_password_security_reused_short": "Reused",
|
||||
"txt_password_security_unavailable": "{count} password checks could not reach the breach database. They are not marked safe.",
|
||||
"txt_password_security_not_checked": "Not checked",
|
||||
"txt_password_exposed_count": "Found in {count} breaches",
|
||||
"txt_password_reused_count": "Used {count} times",
|
||||
"txt_weak_password": "Weak password",
|
||||
"txt_no_password_risks": "No password risks found",
|
||||
"txt_no_password_risks_in_filter": "No password risks in this category",
|
||||
"txt_open_vault": "Open vault",
|
||||
"txt_check_password_breach": "Check breach",
|
||||
"txt_password_not_found_in_breaches": "Not found in the breach database",
|
||||
"txt_password_security_check_failed": "The breach check could not be completed."
|
||||
});
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -7,6 +7,7 @@ const es: Record<string, string> = {
|
||||
"nav_my_vault": "Mi bóveda",
|
||||
"nav_vault_items": "Bóveda",
|
||||
"nav_sends": "Envíos",
|
||||
"nav_generator": "Generador", "txt_password_generator": "Generador de contraseñas", "txt_password_generator_description": "Crea una contraseña única y segura localmente en este dispositivo.", "txt_generator_type": "Tipo de generador", "txt_passphrase": "Frase de contraseña", "txt_generated_password": "Contraseña generada", "txt_password_strength": "Seguridad", "txt_password_strength_weak": "Débil", "txt_password_strength_fair": "Regular", "txt_password_strength_good": "Buena", "txt_password_strength_strong": "Fuerte", "txt_generator_security_note": "La generación se realiza localmente. Tu contraseña nunca se envía al servidor.", "txt_generator_length": "Longitud", "txt_generator_character_types": "Tipos de caracteres", "txt_generator_uppercase": "Mayúsculas (A-Z)", "txt_generator_lowercase": "Minúsculas (a-z)", "txt_generator_numbers": "Números (0-9)", "txt_generator_special": "Caracteres especiales (!@#$%^&*)", "txt_generator_minimum": "Mínimo", "txt_generator_avoid_ambiguous": "Evitar caracteres ambiguos", "txt_generator_words": "Número de palabras", "txt_generator_separator": "Separador de palabras", "txt_generator_capitalize": "Usar mayúsculas", "txt_generator_include_number": "Incluir un número",
|
||||
"nav_backup_strategy": "Copia de seguridad en la nube",
|
||||
"nav_import_export": "Importar y exportar",
|
||||
"nav_group_data_backup": "Datos y copias",
|
||||
@@ -1447,4 +1448,12 @@ const es: Record<string, string> = {
|
||||
"txt_auth_request_missing_public_key": "La solicitud de inicio de sesión con dispositivo no incluye una clave pública"
|
||||
};
|
||||
|
||||
Object.assign(es, {
|
||||
"nav_password_security": "Seguridad de contraseñas", "txt_password_security": "Comprobación de seguridad", "txt_password_security_privacy": "Las contraseñas se comprueban localmente. Solo se envía un prefijo de hash anónimo a la base de filtraciones al iniciar la comprobación.", "txt_check_password_security": "Iniciar comprobación", "txt_checking_password_security": "Comprobando", "txt_recheck_password_security": "Comprobar de nuevo", "txt_password_security_ready": "Tu bóveda está lista para una comprobación de seguridad.", "txt_password_security_no_login": "No hay contraseñas de inicio de sesión para comprobar.", "txt_password_security_manual": "La comprobación solo empieza cuando la eliges. Los resultados se conservan solo en esta página.", "txt_password_security_no_login_help": "Añade un inicio de sesión con contraseña y vuelve aquí para comprobarlo.", "txt_exposed_passwords": "Filtradas", "txt_reused_passwords": "Reutilizadas", "txt_weak_passwords": "Débiles", "txt_passwords_checked": "Comprobadas", "txt_password_security_unavailable": "{count} comprobaciones no pudieron acceder a la base de filtraciones. No se marcan como seguras.", "txt_password_security_not_checked": "Sin comprobar", "txt_password_exposed_count": "Encontrada en {count} filtraciones", "txt_password_reused_count": "Usada {count} veces", "txt_weak_password": "Contraseña débil", "txt_no_password_risks": "No se encontraron riesgos de contraseña", "txt_open_vault": "Abrir bóveda", "txt_check_password_breach": "Comprobar filtración", "txt_password_not_found_in_breaches": "No encontrada en la base de filtraciones", "txt_password_security_check_failed": "No se pudo completar la comprobación de filtraciones."
|
||||
});
|
||||
|
||||
Object.assign(es, { "txt_password_security_last_checked": "Última comprobación: {value}" });
|
||||
Object.assign(es, { "txt_no_password_risks_in_filter": "No hay riesgos de contraseña en esta categoría" });
|
||||
Object.assign(es, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
|
||||
|
||||
export default es;
|
||||
|
||||
@@ -7,6 +7,7 @@ const fi: Record<string, string> = {
|
||||
"nav_my_vault": "Oma holvi",
|
||||
"nav_vault_items": "Holvi",
|
||||
"nav_sends": "Lähetykset",
|
||||
"nav_generator": "Luoja", "txt_password_generator": "Salasanageneraattori", "txt_password_generator_description": "Luo vahva ja yksilöllinen salasana paikallisesti tällä laitteella.", "txt_generator_type": "Generaattorin tyyppi", "txt_passphrase": "Salalause", "txt_generated_password": "Luotu salasana", "txt_password_strength": "Vahvuus", "txt_password_strength_weak": "Heikko", "txt_password_strength_fair": "Kohtalainen", "txt_password_strength_good": "Hyvä", "txt_password_strength_strong": "Vahva", "txt_generator_security_note": "Generointi tapahtuu paikallisesti. Salasanaa ei koskaan lähetetä palvelimelle.", "txt_generator_length": "Pituus", "txt_generator_character_types": "Merkkityypit", "txt_generator_uppercase": "Isot kirjaimet (A-Z)", "txt_generator_lowercase": "Pienet kirjaimet (a-z)", "txt_generator_numbers": "Numerot (0-9)", "txt_generator_special": "Erikoismerkit (!@#$%^&*)", "txt_generator_minimum": "Vähintään", "txt_generator_avoid_ambiguous": "Vältä epäselviä merkkejä", "txt_generator_words": "Sanojen määrä", "txt_generator_separator": "Sanaerotin", "txt_generator_capitalize": "Iso alkukirjain", "txt_generator_include_number": "Sisällytä numero",
|
||||
"nav_backup_strategy": "Pilvivarmuuskopiointi",
|
||||
"nav_import_export": "Tuonti ja Vienti",
|
||||
"nav_group_data_backup": "Data & Varmuuskopiointi",
|
||||
@@ -1447,4 +1448,12 @@ const fi: Record<string, string> = {
|
||||
"txt_ip_address": "IP-osoite"
|
||||
};
|
||||
|
||||
Object.assign(fi, {
|
||||
"nav_password_security": "Salasanasuojaus", "txt_password_security": "Salasanojen turvatarkistus", "txt_password_security_privacy": "Salasanat tarkistetaan paikallisesti. Vain anonyymi hajautteen alku lähetetään vuototietokantaan tarkistuksen alkaessa.", "txt_check_password_security": "Aloita tarkistus", "txt_checking_password_security": "Tarkistetaan", "txt_recheck_password_security": "Tarkista uudelleen", "txt_password_security_ready": "Holvisi on valmis turvatarkistukseen.", "txt_password_security_no_login": "Tarkistettavia kirjautumissalasanoja ei ole.", "txt_password_security_manual": "Tarkistus käynnistyy vain valinnastasi. Tulokset säilyvät vain tällä sivulla.", "txt_password_security_no_login_help": "Lisää kirjautuminen salasanalla ja palaa sitten tarkistamaan se.", "txt_exposed_passwords": "Vuotaneet", "txt_reused_passwords": "Uudelleenkäytetyt", "txt_weak_passwords": "Heikot", "txt_passwords_checked": "Tarkistettu", "txt_password_security_unavailable": "{count} salasanatarkistusta ei tavoittanut vuototietokantaa. Niitä ei merkitä turvallisiksi.", "txt_password_security_not_checked": "Ei tarkistettu", "txt_password_exposed_count": "Löytyi {count} vuodosta", "txt_password_reused_count": "Käytetty {count} kertaa", "txt_weak_password": "Heikko salasana", "txt_no_password_risks": "Salasanariskejä ei löytynyt", "txt_open_vault": "Avaa holvi", "txt_check_password_breach": "Tarkista vuoto", "txt_password_not_found_in_breaches": "Ei löytynyt vuototietokannasta", "txt_password_security_check_failed": "Vuototarkistusta ei voitu suorittaa."
|
||||
});
|
||||
|
||||
Object.assign(fi, { "txt_password_security_last_checked": "Tarkistettu viimeksi: {value}" });
|
||||
Object.assign(fi, { "txt_no_password_risks_in_filter": "Tässä luokassa ei ole salasanojen riskejä" });
|
||||
Object.assign(fi, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
|
||||
|
||||
export default fi;
|
||||
|
||||
@@ -7,6 +7,7 @@ const fr: Record<string, string> = {
|
||||
"nav_my_vault": "Mon coffre-fort",
|
||||
"nav_vault_items": "Coffre-fort",
|
||||
"nav_sends": "Envois",
|
||||
"nav_generator": "Générateur", "txt_password_generator": "Générateur de mots de passe", "txt_password_generator_description": "Créez un mot de passe fort et unique localement sur cet appareil.", "txt_generator_type": "Type de générateur", "txt_passphrase": "Phrase secrète", "txt_generated_password": "Mot de passe généré", "txt_password_strength": "Robustesse", "txt_password_strength_weak": "Faible", "txt_password_strength_fair": "Correcte", "txt_password_strength_good": "Bonne", "txt_password_strength_strong": "Forte", "txt_generator_security_note": "La génération est locale. Votre mot de passe n'est jamais envoyé au serveur.", "txt_generator_length": "Longueur", "txt_generator_character_types": "Types de caractères", "txt_generator_uppercase": "Majuscules (A-Z)", "txt_generator_lowercase": "Minuscules (a-z)", "txt_generator_numbers": "Chiffres (0-9)", "txt_generator_special": "Caractères spéciaux (!@#$%^&*)", "txt_generator_minimum": "Minimum", "txt_generator_avoid_ambiguous": "Éviter les caractères ambigus", "txt_generator_words": "Nombre de mots", "txt_generator_separator": "Séparateur de mots", "txt_generator_capitalize": "Mettre une majuscule", "txt_generator_include_number": "Inclure un chiffre",
|
||||
"nav_backup_strategy": "Sauvegarde Cloud",
|
||||
"nav_import_export": "Importer & Exporter",
|
||||
"nav_group_data_backup": "Données & Sauvegarde",
|
||||
@@ -1447,4 +1448,12 @@ const fr: Record<string, string> = {
|
||||
"txt_ip_address": "Adresse IP"
|
||||
};
|
||||
|
||||
Object.assign(fr, {
|
||||
"nav_password_security": "Sécurité des mots de passe", "txt_password_security": "Vérification de sécurité", "txt_password_security_privacy": "Les mots de passe sont vérifiés localement. Seul un préfixe de hachage anonyme est envoyé à la base de fuites après le démarrage.", "txt_check_password_security": "Lancer la vérification", "txt_checking_password_security": "Vérification", "txt_recheck_password_security": "Vérifier à nouveau", "txt_password_security_ready": "Votre coffre est prêt pour une vérification de sécurité.", "txt_password_security_no_login": "Aucun mot de passe de connexion à vérifier.", "txt_password_security_manual": "La vérification ne démarre que sur votre demande. Les résultats restent sur cette page.", "txt_password_security_no_login_help": "Ajoutez une connexion avec mot de passe, puis revenez ici pour la vérifier.", "txt_exposed_passwords": "Exposés", "txt_reused_passwords": "Réutilisés", "txt_weak_passwords": "Faibles", "txt_passwords_checked": "Vérifiés", "txt_password_security_unavailable": "{count} vérifications n'ont pas pu joindre la base de fuites. Elles ne sont pas marquées comme sûres.", "txt_password_security_not_checked": "Non vérifié", "txt_password_exposed_count": "Trouvé dans {count} fuites", "txt_password_reused_count": "Utilisé {count} fois", "txt_weak_password": "Mot de passe faible", "txt_no_password_risks": "Aucun risque de mot de passe détecté", "txt_open_vault": "Ouvrir le coffre", "txt_check_password_breach": "Vérifier la fuite", "txt_password_not_found_in_breaches": "Introuvable dans la base de fuites", "txt_password_security_check_failed": "La vérification de fuite n'a pas pu être terminée."
|
||||
});
|
||||
|
||||
Object.assign(fr, { "txt_password_security_last_checked": "Dernière vérification : {value}" });
|
||||
Object.assign(fr, { "txt_no_password_risks_in_filter": "Aucun risque de mot de passe dans cette catégorie" });
|
||||
Object.assign(fr, { "txt_password_security_show_all": "Tout afficher", "txt_password_security_hide_all": "Tout masquer", "txt_password_security_jump": "Ouvrir", "txt_password_security_exposed_short": "Exposé {count} fois", "txt_password_security_weak_short": "Mot de passe faible", "txt_password_security_reused_short": "Réutilisé" });
|
||||
|
||||
export default fr;
|
||||
|
||||
@@ -7,6 +7,7 @@ const it: Record<string, string> = {
|
||||
"nav_my_vault": "La mia Cassaforte",
|
||||
"nav_vault_items": "Cassaforte",
|
||||
"nav_sends": "Invii",
|
||||
"nav_generator": "Generatore", "txt_password_generator": "Generatore di password", "txt_password_generator_description": "Crea una password forte e univoca localmente su questo dispositivo.", "txt_generator_type": "Tipo di generatore", "txt_passphrase": "Frase segreta", "txt_generated_password": "Password generata", "txt_password_strength": "Robustezza", "txt_password_strength_weak": "Debole", "txt_password_strength_fair": "Discreta", "txt_password_strength_good": "Buona", "txt_password_strength_strong": "Forte", "txt_generator_security_note": "La generazione avviene localmente. La password non viene mai inviata al server.", "txt_generator_length": "Lunghezza", "txt_generator_character_types": "Tipi di caratteri", "txt_generator_uppercase": "Maiuscole (A-Z)", "txt_generator_lowercase": "Minuscole (a-z)", "txt_generator_numbers": "Numeri (0-9)", "txt_generator_special": "Caratteri speciali (!@#$%^&*)", "txt_generator_minimum": "Minimo", "txt_generator_avoid_ambiguous": "Evita caratteri ambigui", "txt_generator_words": "Numero di parole", "txt_generator_separator": "Separatore di parole", "txt_generator_capitalize": "Iniziale maiuscola", "txt_generator_include_number": "Includi un numero",
|
||||
"nav_backup_strategy": "Backup su Cloud",
|
||||
"nav_import_export": "Importa ed Esporta",
|
||||
"nav_group_data_backup": "Dati e Backup",
|
||||
@@ -1447,4 +1448,12 @@ const it: Record<string, string> = {
|
||||
"txt_ip_address": "Indirizzo IP"
|
||||
};
|
||||
|
||||
Object.assign(it, {
|
||||
"nav_password_security": "Sicurezza password", "txt_password_security": "Controllo sicurezza password", "txt_password_security_privacy": "Le password vengono controllate localmente. Solo un prefisso hash anonimo viene inviato al database delle violazioni dopo l'avvio.", "txt_check_password_security": "Avvia controllo", "txt_checking_password_security": "Controllo in corso", "txt_recheck_password_security": "Controlla di nuovo", "txt_password_security_ready": "Il tuo archivio è pronto per un controllo di sicurezza.", "txt_password_security_no_login": "Non ci sono password di accesso da controllare.", "txt_password_security_manual": "Il controllo parte solo quando lo scegli. I risultati restano solo in questa pagina.", "txt_password_security_no_login_help": "Aggiungi un accesso con password, quindi torna qui per controllarlo.", "txt_exposed_passwords": "Esposte", "txt_reused_passwords": "Riutilizzate", "txt_weak_passwords": "Deboli", "txt_passwords_checked": "Controllate", "txt_password_security_unavailable": "{count} controlli non hanno raggiunto il database delle violazioni. Non sono contrassegnati come sicuri.", "txt_password_security_not_checked": "Non controllata", "txt_password_exposed_count": "Trovata in {count} violazioni", "txt_password_reused_count": "Usata {count} volte", "txt_weak_password": "Password debole", "txt_no_password_risks": "Nessun rischio password trovato", "txt_open_vault": "Apri archivio", "txt_check_password_breach": "Controlla violazione", "txt_password_not_found_in_breaches": "Non trovata nel database delle violazioni", "txt_password_security_check_failed": "Impossibile completare il controllo delle violazioni."
|
||||
});
|
||||
|
||||
Object.assign(it, { "txt_password_security_last_checked": "Ultimo controllo: {value}" });
|
||||
Object.assign(it, { "txt_no_password_risks_in_filter": "Nessun rischio password in questa categoria" });
|
||||
Object.assign(it, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
|
||||
|
||||
export default it;
|
||||
|
||||
@@ -8,6 +8,7 @@ const ru: Record<string, string> = {
|
||||
"nav_my_vault": "Мое хранилище",
|
||||
"nav_vault_items": "Хранилище",
|
||||
"nav_sends": "Отправляет",
|
||||
"nav_generator": "Генератор", "txt_password_generator": "Генератор паролей", "txt_password_generator_description": "Создайте надежный уникальный пароль локально на этом устройстве.", "txt_generator_type": "Тип генератора", "txt_passphrase": "Парольная фраза", "txt_generated_password": "Созданный пароль", "txt_password_strength": "Надежность", "txt_password_strength_weak": "Слабый", "txt_password_strength_fair": "Средний", "txt_password_strength_good": "Хороший", "txt_password_strength_strong": "Надежный", "txt_generator_security_note": "Генерация выполняется локально. Пароль никогда не отправляется на сервер.", "txt_generator_length": "Длина", "txt_generator_character_types": "Типы символов", "txt_generator_uppercase": "Заглавные буквы (A-Z)", "txt_generator_lowercase": "Строчные буквы (a-z)", "txt_generator_numbers": "Цифры (0-9)", "txt_generator_special": "Специальные символы (!@#$%^&*)", "txt_generator_minimum": "Минимум", "txt_generator_avoid_ambiguous": "Исключить похожие символы", "txt_generator_words": "Количество слов", "txt_generator_separator": "Разделитель слов", "txt_generator_capitalize": "С заглавной буквы", "txt_generator_include_number": "Добавить число",
|
||||
"nav_backup_strategy": "Облачное резервное копирование",
|
||||
"nav_import_export": "Импорт и экспорт",
|
||||
"nav_group_data_backup": "Данные и резервные копии",
|
||||
@@ -1447,4 +1448,12 @@ const ru: Record<string, string> = {
|
||||
"txt_auth_request_missing_public_key": "В запросе входа с устройства отсутствует открытый ключ"
|
||||
};
|
||||
|
||||
Object.assign(ru, {
|
||||
"nav_password_security": "Безопасность паролей", "txt_password_security": "Проверка безопасности паролей", "txt_password_security_privacy": "Пароли проверяются локально. После запуска в базу утечек передаётся только анонимный префикс хеша.", "txt_check_password_security": "Начать проверку", "txt_checking_password_security": "Проверка", "txt_recheck_password_security": "Проверить снова", "txt_password_security_ready": "Ваше хранилище готово к проверке безопасности.", "txt_password_security_no_login": "Нет паролей для входа, доступных для проверки.", "txt_password_security_manual": "Проверка запускается только по вашему выбору. Результаты остаются только на этой странице.", "txt_password_security_no_login_help": "Добавьте запись входа с паролем и вернитесь сюда для проверки.", "txt_exposed_passwords": "Скомпрометированы", "txt_reused_passwords": "Повторно используются", "txt_weak_passwords": "Слабые", "txt_passwords_checked": "Проверено", "txt_password_security_unavailable": "{count} проверок не смогли обратиться к базе утечек. Они не помечены безопасными.", "txt_password_security_not_checked": "Не проверено", "txt_password_exposed_count": "Найдено в {count} утечках", "txt_password_reused_count": "Используется {count} раз", "txt_weak_password": "Слабый пароль", "txt_no_password_risks": "Рисков паролей не найдено", "txt_open_vault": "Открыть хранилище", "txt_check_password_breach": "Проверить утечку", "txt_password_not_found_in_breaches": "Не найден в базе утечек", "txt_password_security_check_failed": "Не удалось завершить проверку утечки."
|
||||
});
|
||||
|
||||
Object.assign(ru, { "txt_password_security_last_checked": "Последняя проверка: {value}" });
|
||||
Object.assign(ru, { "txt_no_password_risks_in_filter": "В этой категории нет рисков для паролей" });
|
||||
Object.assign(ru, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
|
||||
|
||||
export default ru;
|
||||
|
||||
@@ -7,6 +7,7 @@ const sv: Record<string, string> = {
|
||||
"nav_my_vault": "Mitt valv",
|
||||
"nav_vault_items": "Valv",
|
||||
"nav_sends": "Skickat",
|
||||
"nav_generator": "Generator", "txt_password_generator": "Lösenordsgenerator", "txt_password_generator_description": "Skapa ett starkt och unikt lösenord lokalt på den här enheten.", "txt_generator_type": "Generatortyp", "txt_passphrase": "Lösenfras", "txt_generated_password": "Genererat lösenord", "txt_password_strength": "Styrka", "txt_password_strength_weak": "Svagt", "txt_password_strength_fair": "Medel", "txt_password_strength_good": "Bra", "txt_password_strength_strong": "Starkt", "txt_generator_security_note": "Generering sker lokalt. Ditt lösenord skickas aldrig till servern.", "txt_generator_length": "Längd", "txt_generator_character_types": "Teckentyper", "txt_generator_uppercase": "Versaler (A-Z)", "txt_generator_lowercase": "Gemener (a-z)", "txt_generator_numbers": "Siffror (0-9)", "txt_generator_special": "Specialtecken (!@#$%^&*)", "txt_generator_minimum": "Minst", "txt_generator_avoid_ambiguous": "Undvik tvetydiga tecken", "txt_generator_words": "Antal ord", "txt_generator_separator": "Ordavgränsare", "txt_generator_capitalize": "Stor begynnelsebokstav", "txt_generator_include_number": "Inkludera en siffra",
|
||||
"nav_backup_strategy": "Molnsäkerhetskopiering",
|
||||
"nav_import_export": "Importera och Exportera",
|
||||
"nav_group_data_backup": "Data och Säkerhetskopiering",
|
||||
@@ -1447,4 +1448,12 @@ const sv: Record<string, string> = {
|
||||
"txt_ip_address": "IP-adress"
|
||||
};
|
||||
|
||||
Object.assign(sv, {
|
||||
"nav_password_security": "Lösenordssäkerhet", "txt_password_security": "Säkerhetskontroll för lösenord", "txt_password_security_privacy": "Lösenord kontrolleras lokalt. Endast ett anonymt hashprefix skickas till läckdatabasen när du startar kontrollen.", "txt_check_password_security": "Starta kontroll", "txt_checking_password_security": "Kontrollerar", "txt_recheck_password_security": "Kontrollera igen", "txt_password_security_ready": "Ditt valv är redo för en säkerhetskontroll.", "txt_password_security_no_login": "Det finns inga inloggningslösenord att kontrollera.", "txt_password_security_manual": "Kontrollen startar bara när du väljer den. Resultaten stannar på denna sida.", "txt_password_security_no_login_help": "Lägg till en inloggning med lösenord och återvänd sedan hit för att kontrollera den.", "txt_exposed_passwords": "Läckta", "txt_reused_passwords": "Återanvända", "txt_weak_passwords": "Svaga", "txt_passwords_checked": "Kontrollerade", "txt_password_security_unavailable": "{count} lösenordskontroller kunde inte nå läckdatabasen. De markeras inte som säkra.", "txt_password_security_not_checked": "Inte kontrollerad", "txt_password_exposed_count": "Hittades i {count} läckor", "txt_password_reused_count": "Användes {count} gånger", "txt_weak_password": "Svagt lösenord", "txt_no_password_risks": "Inga lösenordsrisker hittades", "txt_open_vault": "Öppna valv", "txt_check_password_breach": "Kontrollera läcka", "txt_password_not_found_in_breaches": "Hittades inte i läckdatabasen", "txt_password_security_check_failed": "Läckkontrollen kunde inte slutföras."
|
||||
});
|
||||
|
||||
Object.assign(sv, { "txt_password_security_last_checked": "Senast kontrollerad: {value}" });
|
||||
Object.assign(sv, { "txt_no_password_risks_in_filter": "Inga lösenordsrisker i denna kategori" });
|
||||
Object.assign(sv, { "txt_password_security_show_all": "Show all", "txt_password_security_hide_all": "Hide all", "txt_password_security_jump": "Go to item", "txt_password_security_exposed_short": "Exposed {count} times", "txt_password_security_weak_short": "Weak password", "txt_password_security_reused_short": "Reused" });
|
||||
|
||||
export default sv;
|
||||
|
||||
@@ -7,6 +7,10 @@ const zhCN: Record<string, string> = {
|
||||
"nav_my_vault": "我的密码库",
|
||||
"nav_vault_items": "密码库",
|
||||
"nav_sends": "Send",
|
||||
"nav_generator": "密码生成器",
|
||||
"txt_password_generator": "密码生成器",
|
||||
"txt_password_generator_description": "在此设备本地生成强且唯一的密码。",
|
||||
"txt_generator_type": "生成类型", "txt_passphrase": "密码短语", "txt_generated_password": "已生成密码", "txt_password_strength": "强度", "txt_password_strength_weak": "弱", "txt_password_strength_fair": "一般", "txt_password_strength_good": "良好", "txt_password_strength_strong": "强", "txt_generator_security_note": "生成过程仅在本地进行,密码不会发送到服务器。", "txt_generator_length": "长度", "txt_generator_character_types": "字符类型", "txt_generator_uppercase": "大写字母 (A-Z)", "txt_generator_lowercase": "小写字母 (a-z)", "txt_generator_numbers": "数字 (0-9)", "txt_generator_special": "特殊字符 (!@#$%^&*)", "txt_generator_minimum": "最少数量", "txt_generator_avoid_ambiguous": "避免易混淆字符", "txt_generator_words": "单词数量", "txt_generator_separator": "单词分隔符", "txt_generator_capitalize": "首字母大写", "txt_generator_include_number": "包含数字",
|
||||
"nav_backup_strategy": "云端备份",
|
||||
"nav_import_export": "导入导出",
|
||||
"nav_group_data_backup": "数据与备份",
|
||||
@@ -1447,4 +1451,39 @@ const zhCN: Record<string, string> = {
|
||||
"txt_ip_address": "IP 地址"
|
||||
};
|
||||
|
||||
Object.assign(zhCN, {
|
||||
"nav_password_security": "安全检测",
|
||||
"txt_password_security": "安全检测",
|
||||
"txt_password_security_privacy": "密码仅在本地前端检查;只有匿名哈希前缀会发送到泄露密码库。",
|
||||
"txt_check_password_security": "开始检查",
|
||||
"txt_checking_password_security": "检查中",
|
||||
"txt_recheck_password_security": "重新检查",
|
||||
"txt_password_security_ready": "密码库已准备好进行安全检查。",
|
||||
"txt_password_security_no_login": "没有可检查的登录密码。",
|
||||
"txt_password_security_manual": "仅在您主动开始后才会联网检查;结果会保留到刷新页面或密码库内容变更前。",
|
||||
"txt_password_security_no_login_help": "添加一个包含密码的登录项目后,再回到此处检查。",
|
||||
"txt_exposed_passwords": "已泄露",
|
||||
"txt_reused_passwords": "重复使用",
|
||||
"txt_weak_passwords": "较弱",
|
||||
"txt_passwords_checked": "已检查",
|
||||
"txt_password_security_last_checked": "上次检测:{value}",
|
||||
"txt_password_security_show_all": "显示全部",
|
||||
"txt_password_security_hide_all": "隐藏全部",
|
||||
"txt_password_security_jump": "跳转",
|
||||
"txt_password_security_exposed_short": "泄露 {count} 次",
|
||||
"txt_password_security_weak_short": "弱密码",
|
||||
"txt_password_security_reused_short": "重复",
|
||||
"txt_password_security_unavailable": "有 {count} 个密码无法连接泄露库,未被标记为安全。",
|
||||
"txt_password_security_not_checked": "未检查",
|
||||
"txt_password_exposed_count": "已在 {count} 次泄露中出现",
|
||||
"txt_password_reused_count": "使用了 {count} 次",
|
||||
"txt_weak_password": "较弱密码",
|
||||
"txt_no_password_risks": "未发现密码风险",
|
||||
"txt_no_password_risks_in_filter": "此类别中没有密码风险",
|
||||
"txt_open_vault": "打开密码库",
|
||||
"txt_check_password_breach": "检查泄露",
|
||||
"txt_password_not_found_in_breaches": "未在泄露密码库中发现",
|
||||
"txt_password_security_check_failed": "无法完成泄露检查。"
|
||||
});
|
||||
|
||||
export default zhCN;
|
||||
|
||||
@@ -7,6 +7,10 @@ const zhTW: Record<string, string> = {
|
||||
"nav_my_vault": "我的密碼庫",
|
||||
"nav_vault_items": "密碼庫",
|
||||
"nav_sends": "Send",
|
||||
"nav_generator": "密碼產生器",
|
||||
"txt_password_generator": "密碼產生器",
|
||||
"txt_password_generator_description": "在此裝置本機建立強而唯一的密碼。",
|
||||
"txt_generator_type": "產生類型", "txt_passphrase": "密碼片語", "txt_generated_password": "已產生密碼", "txt_password_strength": "強度", "txt_password_strength_weak": "弱", "txt_password_strength_fair": "普通", "txt_password_strength_good": "良好", "txt_password_strength_strong": "強", "txt_generator_security_note": "產生程序僅在本機進行,密碼不會傳送到伺服器。", "txt_generator_length": "長度", "txt_generator_character_types": "字元類型", "txt_generator_uppercase": "大寫字母 (A-Z)", "txt_generator_lowercase": "小寫字母 (a-z)", "txt_generator_numbers": "數字 (0-9)", "txt_generator_special": "特殊字元 (!@#$%^&*)", "txt_generator_minimum": "最少數量", "txt_generator_avoid_ambiguous": "避免易混淆字元", "txt_generator_words": "單字數量", "txt_generator_separator": "單字分隔符號", "txt_generator_capitalize": "首字母大寫", "txt_generator_include_number": "包含數字",
|
||||
"nav_backup_strategy": "雲端備份",
|
||||
"nav_import_export": "導入導出",
|
||||
"nav_group_data_backup": "資料與備份",
|
||||
@@ -1447,4 +1451,39 @@ const zhTW: Record<string, string> = {
|
||||
"txt_auth_request_missing_public_key": "裝置登入請求缺少公鑰"
|
||||
};
|
||||
|
||||
Object.assign(zhTW, {
|
||||
"nav_password_security": "密碼安全",
|
||||
"txt_password_security": "密碼安全檢查",
|
||||
"txt_password_security_privacy": "密碼僅在本機前端檢查;只有匿名雜湊前綴會傳送到外洩密碼庫。",
|
||||
"txt_check_password_security": "開始檢查",
|
||||
"txt_checking_password_security": "檢查中",
|
||||
"txt_recheck_password_security": "重新檢查",
|
||||
"txt_password_security_ready": "密碼庫已準備好進行安全檢查。",
|
||||
"txt_password_security_no_login": "沒有可檢查的登入密碼。",
|
||||
"txt_password_security_manual": "僅在您主動開始後才會連線檢查;結果會保留到重新整理頁面或密碼庫內容變更前。",
|
||||
"txt_password_security_no_login_help": "新增一個含有密碼的登入項目後,再回到此處檢查。",
|
||||
"txt_exposed_passwords": "已外洩",
|
||||
"txt_reused_passwords": "重複使用",
|
||||
"txt_weak_passwords": "較弱",
|
||||
"txt_passwords_checked": "已檢查",
|
||||
"txt_password_security_last_checked": "上次檢查:{value}",
|
||||
"txt_password_security_show_all": "顯示全部",
|
||||
"txt_password_security_hide_all": "隱藏全部",
|
||||
"txt_password_security_jump": "跳轉",
|
||||
"txt_password_security_exposed_short": "外洩 {count} 次",
|
||||
"txt_password_security_weak_short": "弱密碼",
|
||||
"txt_password_security_reused_short": "重複",
|
||||
"txt_password_security_unavailable": "有 {count} 個密碼無法連線至外洩資料庫,未被標記為安全。",
|
||||
"txt_password_security_not_checked": "未檢查",
|
||||
"txt_password_exposed_count": "已在 {count} 次外洩中出現",
|
||||
"txt_password_reused_count": "使用了 {count} 次",
|
||||
"txt_weak_password": "較弱密碼",
|
||||
"txt_no_password_risks": "未發現密碼風險",
|
||||
"txt_no_password_risks_in_filter": "此類別中沒有密碼風險",
|
||||
"txt_open_vault": "開啟密碼庫",
|
||||
"txt_check_password_breach": "檢查外洩",
|
||||
"txt_password_not_found_in_breaches": "未在外洩密碼庫中發現",
|
||||
"txt_password_security_check_failed": "無法完成外洩檢查。"
|
||||
});
|
||||
|
||||
export default zhTW;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { inspectVaultPasswordSecurity, type PasswordSecurityReport } from '@/lib/password-security';
|
||||
import type { Cipher } from '@/lib/types';
|
||||
|
||||
export interface PasswordSecurityState {
|
||||
fingerprint: string;
|
||||
report: PasswordSecurityReport | null;
|
||||
scannedAt: number | null;
|
||||
scanning: boolean;
|
||||
progress: { checked: number; total: number };
|
||||
scanError: boolean;
|
||||
}
|
||||
|
||||
type InternalPasswordSecurityState = PasswordSecurityState & { controller: AbortController | null };
|
||||
|
||||
let state: InternalPasswordSecurityState | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function notify(): void {
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function createState(fingerprint: string): InternalPasswordSecurityState {
|
||||
return { fingerprint, report: null, scannedAt: null, scanning: false, progress: { checked: 0, total: 0 }, scanError: false, controller: null };
|
||||
}
|
||||
|
||||
export function getPasswordSecurityState(fingerprint: string): PasswordSecurityState {
|
||||
if (state?.fingerprint !== fingerprint) {
|
||||
state?.controller?.abort();
|
||||
state = createState(fingerprint);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function readPasswordSecurityState(fingerprint: string): PasswordSecurityState | null {
|
||||
return state?.fingerprint === fingerprint ? state : null;
|
||||
}
|
||||
|
||||
export function subscribePasswordSecurityState(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function startPasswordSecurityScan(fingerprint: string, ciphers: Cipher[]): void {
|
||||
const current = getPasswordSecurityState(fingerprint);
|
||||
current.controller?.abort();
|
||||
const controller = new AbortController();
|
||||
const total = ciphers.filter((cipher) => Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword).length;
|
||||
state = { ...current, report: null, scannedAt: null, scanning: true, progress: { checked: 0, total }, scanError: false, controller };
|
||||
notify();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const report = await inspectVaultPasswordSecurity(ciphers, (checked, total) => {
|
||||
if (controller.signal.aborted || state?.controller !== controller) return;
|
||||
state = { ...state, progress: { checked, total } };
|
||||
notify();
|
||||
}, fetch, controller.signal);
|
||||
if (controller.signal.aborted || state?.controller !== controller) return;
|
||||
state = { ...state, report, scannedAt: Date.now() };
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || (error as { name?: string } | null)?.name === 'AbortError') return;
|
||||
if (state?.controller === controller) state = { ...state, scanError: true };
|
||||
} finally {
|
||||
if (state?.controller === controller) state = { ...state, controller: null, scanning: false };
|
||||
notify();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export function clearPasswordSecurityCache(): void {
|
||||
state?.controller?.abort();
|
||||
state = null;
|
||||
notify();
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import type { Cipher } from '@/lib/types';
|
||||
|
||||
const PWNED_PASSWORDS_RANGE_URL = 'https://api.pwnedpasswords.com/range/';
|
||||
const MAX_CONCURRENT_BREACH_CHECKS = 5;
|
||||
const COMMON_PASSWORDS = new Set([
|
||||
'password', 'password1', '123456', '12345678', '123456789', 'qwerty', 'abc123', 'letmein', 'welcome', 'iloveyou', 'admin', 'changeme',
|
||||
]);
|
||||
|
||||
export interface PasswordBreachResult {
|
||||
count: number | null;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface PasswordSecurityItem {
|
||||
cipherId: string;
|
||||
exposedCount: number | null;
|
||||
reusedCount: number;
|
||||
weak: boolean;
|
||||
}
|
||||
|
||||
export interface PasswordSecurityReport {
|
||||
eligibleCount: number;
|
||||
checkedCount: number;
|
||||
exposedCount: number;
|
||||
reusedCount: number;
|
||||
weakCount: number;
|
||||
unavailableCount: number;
|
||||
items: PasswordSecurityItem[];
|
||||
}
|
||||
|
||||
type Candidate = {
|
||||
cipherId: string;
|
||||
name: string;
|
||||
hash: string;
|
||||
weak: boolean;
|
||||
};
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('').toUpperCase();
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
return !!error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError';
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
const error = new Error('The operation was aborted.');
|
||||
error.name = 'AbortError';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sha1Password(password: string): Promise<string> {
|
||||
const input = new TextEncoder().encode(password);
|
||||
return bytesToHex(new Uint8Array(await crypto.subtle.digest('SHA-1', input)));
|
||||
}
|
||||
|
||||
function parseRangeResponse(text: string, suffix: string): number {
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
const separator = line.indexOf(':');
|
||||
if (separator !== 35) continue;
|
||||
if (line.slice(0, separator).toUpperCase() !== suffix) continue;
|
||||
const count = Number.parseInt(line.slice(separator + 1), 10);
|
||||
return Number.isSafeInteger(count) && count > 0 ? count : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export async function checkPasswordHashLeaked(
|
||||
hash: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
signal?: AbortSignal,
|
||||
): Promise<number> {
|
||||
if (!/^[A-F0-9]{40}$/.test(hash)) throw new Error('Password hash is invalid.');
|
||||
throwIfAborted(signal);
|
||||
const controller = new AbortController();
|
||||
const timeout = globalThis.setTimeout(() => controller.abort(), 12_000);
|
||||
const onExternalAbort = () => controller.abort();
|
||||
signal?.addEventListener('abort', onExternalAbort, { once: true });
|
||||
if (signal?.aborted) controller.abort();
|
||||
try {
|
||||
const response = await fetchImpl(`${PWNED_PASSWORDS_RANGE_URL}${hash.slice(0, 5)}`, {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
credentials: 'omit',
|
||||
cache: 'no-store',
|
||||
referrerPolicy: 'no-referrer',
|
||||
headers: { 'Add-Padding': 'true' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Pwned Passwords returned ${response.status}.`);
|
||||
return parseRangeResponse(await response.text(), hash.slice(5));
|
||||
} catch (error) {
|
||||
// External cancel (leave page / re-scan) must stay distinguishable from timeout/network failures.
|
||||
if (signal?.aborted) {
|
||||
const abortError = new Error('The operation was aborted.');
|
||||
abortError.name = 'AbortError';
|
||||
throw abortError;
|
||||
}
|
||||
if (isAbortError(error)) throw new Error('Pwned Passwords request timed out.');
|
||||
throw error;
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeout);
|
||||
signal?.removeEventListener('abort', onExternalAbort);
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkPasswordLeaked(
|
||||
password: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
signal?: AbortSignal,
|
||||
): Promise<PasswordBreachResult> {
|
||||
if (!password) return { count: 0, available: true };
|
||||
try {
|
||||
return { count: await checkPasswordHashLeaked(await sha1Password(password), fetchImpl, signal), available: true };
|
||||
} catch (error) {
|
||||
if (isAbortError(error) || signal?.aborted) throw error;
|
||||
return { count: null, available: false };
|
||||
}
|
||||
}
|
||||
|
||||
function hasSimpleSequence(value: string): boolean {
|
||||
const normalized = value.toLowerCase();
|
||||
return ['0123456789', '9876543210', 'abcdefghijklmnopqrstuvwxyz', 'zyxwvutsrqponmlkjihgfedcba', 'qwertyuiop', 'poiuytrewq']
|
||||
.some((sequence) => sequence.includes(normalized) || normalized.includes(sequence.slice(0, 5)));
|
||||
}
|
||||
|
||||
export function isWeakPassword(password: string, username: string = ''): boolean {
|
||||
const normalized = password.toLowerCase();
|
||||
const compactUsername = username.split('@')[0]?.trim().toLowerCase() || '';
|
||||
if (COMMON_PASSWORDS.has(normalized) || password.length < 10) return true;
|
||||
if (/^(.)\1+$/.test(password) || hasSimpleSequence(password)) return true;
|
||||
if (compactUsername.length >= 3 && normalized.includes(compactUsername)) return true;
|
||||
const classes = [/[a-z]/.test(password), /[A-Z]/.test(password), /\d/.test(password), /[^A-Za-z0-9]/.test(password)].filter(Boolean).length;
|
||||
return password.length < 14 && classes < 3;
|
||||
}
|
||||
|
||||
function isEligibleCipher(cipher: Cipher): boolean {
|
||||
return Number(cipher.type) === 1 && !cipher.deletedDate && !(cipher as { deletedAt?: string | null }).deletedAt && !!cipher.login?.decPassword;
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, R>(
|
||||
values: T[],
|
||||
limit: number,
|
||||
worker: (value: T) => Promise<R>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(values.length);
|
||||
let nextIndex = 0;
|
||||
const run = async () => {
|
||||
while (true) {
|
||||
throwIfAborted(signal);
|
||||
const index = nextIndex;
|
||||
nextIndex += 1;
|
||||
if (index >= values.length) return;
|
||||
results[index] = await worker(values[index]);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, values.length) }, run));
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function inspectVaultPasswordSecurity(
|
||||
ciphers: Cipher[],
|
||||
onProgress?: (checked: number, total: number) => void,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
signal?: AbortSignal,
|
||||
): Promise<PasswordSecurityReport> {
|
||||
throwIfAborted(signal);
|
||||
const eligible = ciphers.filter(isEligibleCipher);
|
||||
const candidates: Candidate[] = await Promise.all(eligible.map(async (cipher) => {
|
||||
throwIfAborted(signal);
|
||||
const password = String(cipher.login?.decPassword || '');
|
||||
const username = String(cipher.login?.decUsername || '');
|
||||
return {
|
||||
cipherId: cipher.id,
|
||||
name: String(cipher.decName || cipher.name || ''),
|
||||
hash: await sha1Password(password),
|
||||
weak: isWeakPassword(password, username),
|
||||
};
|
||||
}));
|
||||
const candidatesByHash = new Map<string, Candidate[]>();
|
||||
for (const candidate of candidates) {
|
||||
const group = candidatesByHash.get(candidate.hash) || [];
|
||||
group.push(candidate);
|
||||
candidatesByHash.set(candidate.hash, group);
|
||||
}
|
||||
|
||||
const exposureByHash = new Map<string, PasswordBreachResult>();
|
||||
let checked = 0;
|
||||
await mapWithConcurrency([...candidatesByHash.keys()], MAX_CONCURRENT_BREACH_CHECKS, async (hash) => {
|
||||
throwIfAborted(signal);
|
||||
let result: PasswordBreachResult;
|
||||
try {
|
||||
result = { count: await checkPasswordHashLeaked(hash, fetchImpl, signal), available: true };
|
||||
} catch (error) {
|
||||
if (isAbortError(error) || signal?.aborted) throw error;
|
||||
result = { count: null, available: false };
|
||||
}
|
||||
exposureByHash.set(hash, result);
|
||||
checked += candidatesByHash.get(hash)?.length || 0;
|
||||
onProgress?.(Math.min(checked, candidates.length), candidates.length);
|
||||
return result;
|
||||
}, signal);
|
||||
|
||||
throwIfAborted(signal);
|
||||
|
||||
const items = candidates.map((candidate) => {
|
||||
const exposure = exposureByHash.get(candidate.hash) || { count: null, available: false };
|
||||
return {
|
||||
cipherId: candidate.cipherId,
|
||||
exposedCount: exposure.count,
|
||||
reusedCount: candidatesByHash.get(candidate.hash)?.length || 1,
|
||||
weak: candidate.weak,
|
||||
};
|
||||
}).filter((item) => item.exposedCount === null || (item.exposedCount || 0) > 0 || item.reusedCount > 1 || item.weak)
|
||||
.sort((a, b) => (Number(b.exposedCount || 0) - Number(a.exposedCount || 0)) || (b.reusedCount - a.reusedCount) || Number(b.weak) - Number(a.weak) || a.cipherId.localeCompare(b.cipherId));
|
||||
|
||||
return {
|
||||
eligibleCount: candidates.length,
|
||||
checkedCount: checked,
|
||||
exposedCount: candidates.filter((candidate) => (exposureByHash.get(candidate.hash)?.count || 0) > 0).length,
|
||||
reusedCount: candidates.filter((candidate) => (candidatesByHash.get(candidate.hash)?.length || 0) > 1).length,
|
||||
weakCount: candidates.filter((candidate) => candidate.weak).length,
|
||||
unavailableCount: candidates.filter((candidate) => exposureByHash.get(candidate.hash)?.count === null).length,
|
||||
items,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
@import './styles/base.css';
|
||||
@import './styles/auth.css';
|
||||
@import './styles/forms.css';
|
||||
@import './styles/generator.css';
|
||||
@import './styles/password-security.css';
|
||||
@import './styles/shell.css';
|
||||
@import './styles/vault.css';
|
||||
@import './styles/management.css';
|
||||
@@ -428,7 +430,7 @@ h4 {
|
||||
min-height: min(640px, calc(100dvh - 180px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-home-section {
|
||||
@@ -675,7 +677,7 @@ h4 {
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 0px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
.generator-page {
|
||||
width: min(100%, 1180px);
|
||||
margin: 0;
|
||||
padding: 4px 0 28px;
|
||||
}
|
||||
|
||||
.generator-layout { display: grid; grid-template-columns: minmax(300px, .82fr) minmax(0, 1.18fr); grid-template-areas: 'options output'; gap: 16px; align-items: start; }
|
||||
.generator-output-card, .generator-options-card { border: 1px solid var(--line); border-radius: 20px; background: var(--panel); box-shadow: var(--shadow-sm); }
|
||||
.generator-output-card { grid-area: output; padding: 20px; }
|
||||
.generator-options-card { grid-area: options; padding: 19px; }
|
||||
.generator-options-card h2 { margin: 0 0 18px; font-size: 17px; }
|
||||
.generator-value { display: block; min-height: 110px; margin: 18px 0 10px; padding: 18px; border: 1px solid color-mix(in srgb, var(--primary) 22%, var(--line)); border-radius: 16px; background: color-mix(in srgb, var(--primary) 5%, var(--panel)); color: var(--text); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: clamp(19px, 2.2vw, 27px); font-weight: 700; line-height: 1.45; overflow-wrap: anywhere; user-select: all; }
|
||||
.generator-strength-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--muted-strong); font-size: 13px; font-weight: 700; }
|
||||
.generator-strength-row > span, .generator-security-note { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.generator-strength { display: flex; flex: 1; gap: 4px; }
|
||||
.generator-strength span { height: 5px; flex: 1; border-radius: 999px; background: var(--line); }
|
||||
.generator-strength span.active.level-1 { background: #e87171; }.generator-strength span.active.level-2 { background: #db9b38; }.generator-strength span.active.level-3 { background: #46936c; }.generator-strength span.active.level-4 { background: var(--primary); }
|
||||
.generator-actions { margin-top: 22px; }.generator-actions .btn { flex: 1; }
|
||||
.generator-security-note { margin: 18px 0 0; color: var(--muted); font-size: 12px; line-height: 1.45; }.generator-security-note svg { color: var(--success); flex: 0 0 auto; }
|
||||
.generator-number-field { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 14px; margin-bottom: 15px; color: var(--text); font-size: 14px; font-weight: 700; }.generator-number-field > label { min-width: 0; }.generator-stepper { display: grid; grid-template-columns: 38px 64px 38px; align-items: center; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }.generator-stepper button { display: grid; width: 38px; height: 40px; place-items: center; border: 0; background: transparent; color: var(--primary-strong); cursor: pointer; transition: background-color 160ms ease, color 160ms ease; }.generator-stepper button:hover:not(:disabled) { background: color-mix(in srgb, var(--primary) 10%, var(--panel)); }.generator-stepper button:active:not(:disabled) { background: color-mix(in srgb, var(--primary) 17%, var(--panel)); }.generator-stepper button:focus-visible { position: relative; z-index: 1; outline: 3px solid color-mix(in srgb, var(--primary) 35%, transparent); outline-offset: -3px; }.generator-stepper button:disabled { color: var(--muted); cursor: not-allowed; }.generator-stepper .input { width: 64px; height: 40px; min-width: 0; border: 0; border-radius: 0; padding: 0; background: transparent; text-align: center; font-variant-numeric: tabular-nums; }.generator-stepper .input:focus { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--primary) 36%, transparent); }.generator-number-field.compact { grid-template-columns: minmax(0, 1fr) auto; margin: -3px 0 2px 50px; color: var(--muted); font-size: 13px; }.generator-number-field.compact .generator-stepper { grid-template-columns: 32px 46px 32px; border-radius: 9px; }.generator-number-field.compact .generator-stepper button { width: 32px; height: 34px; }.generator-number-field.compact .generator-stepper .input { width: 46px; height: 34px; font-size: 13px; }
|
||||
.generator-option-group { display: grid; gap: 9px; margin: 18px 0; padding: 0; border: 0; }.generator-option-group legend { margin-bottom: 10px; padding: 0; font-size: 14px; font-weight: 700; }
|
||||
.generator-toggle { display: grid; grid-template-columns: 40px minmax(0, 1fr); align-items: center; gap: 10px; min-height: 32px; cursor: pointer; }.generator-toggle input { position: absolute; opacity: 0; }.generator-toggle > span { position: relative; width: 38px; height: 22px; border-radius: 999px; background: #cbd5e1; transition: background 180ms ease; }.generator-toggle > span::after { position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(15,23,42,.25); content: ''; transition: transform 180ms ease; }.generator-toggle input:checked + span { background: var(--primary); }.generator-toggle input:checked + span::after { transform: translateX(16px); }.generator-toggle input:focus-visible + span { outline: 3px solid color-mix(in srgb, var(--primary) 30%, transparent); outline-offset: 2px; }.generator-toggle strong { font-size: 14px; font-weight: 600; }
|
||||
.generator-inline-number { display: grid; grid-template-columns: minmax(0, 1fr) 72px; align-items: center; gap: 14px; margin: -3px 0 2px 50px; color: var(--muted); font-size: 13px; }.generator-inline-number .input { height: 34px; text-align: center; }
|
||||
@media (max-width: 760px) { .generator-page { width: 100%; padding: 0 0 18px; }.generator-layout { grid-template-columns: 1fr; grid-template-areas: 'output' 'options'; gap: 10px; }.generator-output-card, .generator-options-card { padding: 15px; border-radius: 16px; }.generator-value { min-height: 94px; margin: 14px 0 10px; padding: 14px; font-size: 19px; }.generator-actions .btn { justify-content: center; padding-inline: 10px; }.generator-option-group { margin: 15px 0; }.generator-toggle { min-height: 44px; }.generator-number-field.compact { margin-left: 50px; }.generator-stepper { grid-template-columns: 40px 64px 40px; }.generator-stepper button { width: 40px; min-height: 44px; }.generator-stepper .input { height: 44px; }.generator-number-field.compact .generator-stepper { grid-template-columns: 36px 46px 36px; }.generator-number-field.compact .generator-stepper button { width: 36px; height: 40px; min-height: 40px; } }
|
||||
@media (prefers-reduced-motion: reduce) { .generator-toggle > span, .generator-toggle > span::after { transition: none; } }
|
||||
@@ -0,0 +1,77 @@
|
||||
.password-security-page { width: min(100%, 1180px); margin: 0; display: grid; gap: 10px; padding: 4px 0 24px; }
|
||||
.password-security-intro { display: flex; align-items: center; gap: 12px; padding: 14px 16px; }
|
||||
.password-security-intro-icon { width: 42px; height: 42px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 14px; color: var(--primary-strong); background: color-mix(in srgb, var(--primary) 12%, var(--panel)); }
|
||||
.password-security-intro h2 { margin: 0 0 3px; font-size: 18px; }
|
||||
.password-security-intro p { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
|
||||
.password-security-intro .password-security-checked-at { margin-top: 4px; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.password-security-intro-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
|
||||
.password-security-scan, .password-security-toggle-all { min-height: 40px; }
|
||||
.password-security-empty { min-height: 190px; display: grid; place-items: center; align-content: center; gap: 9px; text-align: center; color: var(--muted); padding: 28px; }
|
||||
.password-security-empty > svg { color: var(--primary); }
|
||||
.password-security-empty strong { color: var(--ink); }
|
||||
.password-security-empty span { font-size: 14px; max-width: 520px; line-height: 1.5; }
|
||||
.password-security-empty.compact { min-height: 150px; }
|
||||
.password-security-summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
|
||||
.password-security-metric { display: flex; align-items: center; gap: 10px; min-height: 72px; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--panel); box-shadow: var(--shadow-sm); color: inherit; font: inherit; text-align: left; cursor: pointer; transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; }
|
||||
.password-security-metric:hover:not(:disabled), .password-security-metric[aria-pressed='true'] { border-color: var(--primary); box-shadow: var(--shadow-md); }
|
||||
.password-security-metric:active:not(:disabled) { transform: scale(.99); }
|
||||
.password-security-metric:focus-visible { outline: 3px solid color-mix(in srgb, var(--primary) 45%, transparent); outline-offset: 2px; }
|
||||
.password-security-metric:disabled { cursor: default; }
|
||||
.password-security-metric > span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 12px; }
|
||||
.password-security-metric.danger > span { color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, var(--panel)); }
|
||||
.password-security-metric.warning > span { color: #b45309; background: #fff7e6; }
|
||||
.password-security-metric.primary > span { color: var(--primary-strong); background: color-mix(in srgb, var(--primary) 12%, var(--panel)); }
|
||||
.password-security-metric div { display: grid; gap: 1px; min-width: 0; }
|
||||
.password-security-metric strong { font-size: 20px; line-height: 1.15; font-variant-numeric: tabular-nums; }
|
||||
.password-security-metric small { color: var(--muted); font-size: 12px; }
|
||||
.password-security-results { padding: 8px; }
|
||||
.password-security-notice { display: flex; align-items: center; gap: 8px; padding: 9px 10px; margin-bottom: 8px; border-radius: var(--radius-md); font-size: 13px; }
|
||||
.password-security-notice.warning { color: #92400e; background: #fff7e6; border: 1px solid #fcd8a3; }
|
||||
.password-security-list { display: grid; }
|
||||
.password-security-item { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-height: 64px; padding: 10px; border-bottom: 1px solid var(--line-soft); }
|
||||
.password-security-item:last-child { border-bottom: 0; }
|
||||
.password-security-item-main { min-width: 0; display: grid; gap: 5px; }
|
||||
.password-security-item-header { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.password-security-item-header > strong { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.password-security-password { min-width: 0; color: var(--muted); font-size: 13px; font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.password-security-item-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 6px; }
|
||||
.password-security-badges { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.risk-badge { display: inline-flex; align-items: center; min-height: 22px; padding: 2px 7px; border-radius: 999px; font-size: 12px; font-weight: 600; }
|
||||
.risk-badge.danger { color: #b42318; background: #fef0ef; }
|
||||
.risk-badge.reused { color: #92400e; background: #fff7e6; }
|
||||
.risk-badge.weak { color: #5b21b6; background: #f3e8ff; }
|
||||
.risk-badge.muted { color: var(--muted); background: var(--panel-soft); }
|
||||
.password-security-open { flex: 0 0 auto; }
|
||||
.password-breach-inline { display: flex; align-items: center; gap: 7px; margin-top: 9px; padding: 9px 10px; border-radius: var(--radius-md); font-size: 13px; line-height: 1.35; }
|
||||
.password-breach-inline.safe { color: #16704d; background: #ecfdf3; border: 1px solid #b7ebcd; }
|
||||
.password-breach-inline.danger { color: #b42318; background: #fef0ef; border: 1px solid #fecdc9; }
|
||||
.password-breach-inline.warning { color: #92400e; background: #fff7e6; border: 1px solid #fcd8a3; }
|
||||
.spin { animation: password-security-spin 900ms linear infinite; }
|
||||
@keyframes password-security-spin { to { transform: rotate(360deg); } }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.password-security-page { width: 100%; padding: 0 0 18px; gap: 10px; }
|
||||
.password-security-intro { align-items: flex-start; padding: 14px; }
|
||||
.password-security-intro-icon { width: 38px; height: 38px; border-radius: 12px; }
|
||||
.password-security-intro h2 { font-size: 16px; }
|
||||
.password-security-intro p { font-size: 13px; }
|
||||
.password-security-intro-actions { width: 100%; margin: 8px 0 0; grid-column: 1 / -1; }
|
||||
.password-security-scan, .password-security-toggle-all { flex: 1 1 0; }
|
||||
.password-security-intro { display: grid; grid-template-columns: auto minmax(0, 1fr); }
|
||||
.password-security-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.password-security-metric { min-height: 74px; padding: 12px; gap: 8px; }
|
||||
.password-security-metric > span { width: 32px; height: 32px; border-radius: 10px; }
|
||||
.password-security-metric strong { font-size: 18px; }
|
||||
.password-security-item { align-items: stretch; flex-direction: column; gap: 8px; }
|
||||
.password-security-item-actions { width: 100%; }
|
||||
.password-security-item-actions > * { flex: 1 1 0; min-height: 40px; }
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .password-security-metric.warning > span,
|
||||
:root[data-theme='dark'] .password-security-notice.warning,
|
||||
:root[data-theme='dark'] .risk-badge.reused,
|
||||
:root[data-theme='dark'] .password-breach-inline.warning { color: #fbbf24; background: rgba(180, 83, 9, .18); border-color: rgba(251, 191, 36, .25); }
|
||||
:root[data-theme='dark'] .risk-badge.danger { color: #fca5a5; background: rgba(180, 35, 24, .2); }
|
||||
:root[data-theme='dark'] .risk-badge.weak { color: #d8b4fe; background: rgba(91, 33, 182, .22); }
|
||||
:root[data-theme='dark'] .password-breach-inline.safe { color: #6ee7b7; background: rgba(22, 112, 77, .2); border-color: rgba(110, 231, 183, .25); }
|
||||
:root[data-theme='dark'] .password-breach-inline.danger { color: #fca5a5; background: rgba(180, 35, 24, .2); border-color: rgba(252, 165, 165, .25); }
|
||||
@@ -234,7 +234,7 @@
|
||||
|
||||
.mobile-tabbar {
|
||||
@apply grid items-center gap-1.5;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
min-height: var(--mobile-tabbar-height);
|
||||
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid var(--line);
|
||||
|
||||
Reference in New Issue
Block a user