(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) {
+
+ {breachResult && (
+
+ {breachResult.available ? (breachResult.count ?
:
) :
}
+
{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')}
+
+ )}
{!!props.selectedCipher.login.decTotp && (
{t('txt_totp')}
diff --git a/webapp/src/components/vault/VaultSidebar.tsx b/webapp/src/components/vault/VaultSidebar.tsx
index 8d2a5f2..0e8d00e 100644
--- a/webapp/src/components/vault/VaultSidebar.tsx
+++ b/webapp/src/components/vault/VaultSidebar.tsx
@@ -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) {
)}
+
+
{t('nav_password_security')}
+
diff --git a/webapp/src/lib/i18n/locales/de.ts b/webapp/src/lib/i18n/locales/de.ts
index 86dcc21..99fbcec 100644
--- a/webapp/src/lib/i18n/locales/de.ts
+++ b/webapp/src/lib/i18n/locales/de.ts
@@ -1448,4 +1448,12 @@ const de: Record
= {
"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;
diff --git a/webapp/src/lib/i18n/locales/en.ts b/webapp/src/lib/i18n/locales/en.ts
index 98aa102..8a927c6 100644
--- a/webapp/src/lib/i18n/locales/en.ts
+++ b/webapp/src/lib/i18n/locales/en.ts
@@ -1471,4 +1471,39 @@ const en: Record = {
"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;
diff --git a/webapp/src/lib/i18n/locales/es.ts b/webapp/src/lib/i18n/locales/es.ts
index 2aa684e..fa8f122 100644
--- a/webapp/src/lib/i18n/locales/es.ts
+++ b/webapp/src/lib/i18n/locales/es.ts
@@ -1448,4 +1448,12 @@ const es: Record = {
"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;
diff --git a/webapp/src/lib/i18n/locales/fi.ts b/webapp/src/lib/i18n/locales/fi.ts
index c27d97a..f561ed2 100644
--- a/webapp/src/lib/i18n/locales/fi.ts
+++ b/webapp/src/lib/i18n/locales/fi.ts
@@ -1448,4 +1448,12 @@ const fi: Record = {
"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;
diff --git a/webapp/src/lib/i18n/locales/fr.ts b/webapp/src/lib/i18n/locales/fr.ts
index 2352119..0ced531 100644
--- a/webapp/src/lib/i18n/locales/fr.ts
+++ b/webapp/src/lib/i18n/locales/fr.ts
@@ -1448,4 +1448,12 @@ const fr: Record = {
"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;
diff --git a/webapp/src/lib/i18n/locales/it.ts b/webapp/src/lib/i18n/locales/it.ts
index b807cf8..1f882c1 100644
--- a/webapp/src/lib/i18n/locales/it.ts
+++ b/webapp/src/lib/i18n/locales/it.ts
@@ -1448,4 +1448,12 @@ const it: Record = {
"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;
diff --git a/webapp/src/lib/i18n/locales/ru.ts b/webapp/src/lib/i18n/locales/ru.ts
index 973a7c0..8a97d4b 100644
--- a/webapp/src/lib/i18n/locales/ru.ts
+++ b/webapp/src/lib/i18n/locales/ru.ts
@@ -1448,4 +1448,12 @@ const ru: Record = {
"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;
diff --git a/webapp/src/lib/i18n/locales/sv.ts b/webapp/src/lib/i18n/locales/sv.ts
index 2d9c72a..9baf63d 100644
--- a/webapp/src/lib/i18n/locales/sv.ts
+++ b/webapp/src/lib/i18n/locales/sv.ts
@@ -1448,4 +1448,12 @@ const sv: Record = {
"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;
diff --git a/webapp/src/lib/i18n/locales/zh-CN.ts b/webapp/src/lib/i18n/locales/zh-CN.ts
index 7836132..90ed5ed 100644
--- a/webapp/src/lib/i18n/locales/zh-CN.ts
+++ b/webapp/src/lib/i18n/locales/zh-CN.ts
@@ -1451,4 +1451,39 @@ const zhCN: Record = {
"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;
diff --git a/webapp/src/lib/i18n/locales/zh-TW.ts b/webapp/src/lib/i18n/locales/zh-TW.ts
index 705d527..7a689fc 100644
--- a/webapp/src/lib/i18n/locales/zh-TW.ts
+++ b/webapp/src/lib/i18n/locales/zh-TW.ts
@@ -1451,4 +1451,39 @@ const zhTW: Record = {
"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;
diff --git a/webapp/src/lib/password-security-cache.ts b/webapp/src/lib/password-security-cache.ts
new file mode 100644
index 0000000..7c74c31
--- /dev/null
+++ b/webapp/src/lib/password-security-cache.ts
@@ -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();
+}
diff --git a/webapp/src/lib/password-security.ts b/webapp/src/lib/password-security.ts
new file mode 100644
index 0000000..abb949e
--- /dev/null
+++ b/webapp/src/lib/password-security.ts
@@ -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 {
+ 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 {
+ 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 {
+ 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(
+ values: T[],
+ limit: number,
+ worker: (value: T) => Promise,
+ signal?: AbortSignal,
+): Promise {
+ const results = new Array(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 {
+ 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();
+ for (const candidate of candidates) {
+ const group = candidatesByHash.get(candidate.hash) || [];
+ group.push(candidate);
+ candidatesByHash.set(candidate.hash, group);
+ }
+
+ const exposureByHash = new Map();
+ 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,
+ };
+}
diff --git a/webapp/src/styles.css b/webapp/src/styles.css
index 5036379..b7cdcfb 100644
--- a/webapp/src/styles.css
+++ b/webapp/src/styles.css
@@ -3,6 +3,7 @@
@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';
@@ -429,7 +430,7 @@ h4 {
min-height: min(640px, calc(100dvh - 180px));
display: flex;
flex-direction: column;
- gap: 18px;
+ gap: 10px;
}
.settings-home-section {
@@ -676,7 +677,7 @@ h4 {
}
.card {
- margin-bottom: 8px;
+ margin-bottom: 0px;
padding: 14px;
}
diff --git a/webapp/src/styles/password-security.css b/webapp/src/styles/password-security.css
new file mode 100644
index 0000000..19f95a6
--- /dev/null
+++ b/webapp/src/styles/password-security.css
@@ -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); }