05/20 Modify Vault, Security Dashboard UI
This commit is contained in:
+205
-96
@@ -313,28 +313,50 @@ ul {
|
|||||||
color: #1558b0;
|
color: #1558b0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Vault list items ────────────────────────────────────────────── */
|
/* ── Vault list — card grid layout ───────────────────────────────── */
|
||||||
|
#vault-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Group header spans all columns */
|
||||||
|
.vault-group-header {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
.vault-item {
|
.vault-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 14px;
|
padding: 14px;
|
||||||
border-bottom: 1px solid var(--color-border);
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
transition: background var(--transition);
|
transition: box-shadow var(--transition), border-color var(--transition);
|
||||||
}
|
|
||||||
|
|
||||||
.vault-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.vault-item:hover {
|
.vault-item:hover {
|
||||||
background: #f9fafb;
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
|
||||||
|
border-color: #d1d5db;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-icon {
|
/* Initials avatar */
|
||||||
font-size: 20px;
|
.item-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #fde8ea;
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-info {
|
.item-info {
|
||||||
@@ -369,6 +391,31 @@ ul {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Strength pill inside card */
|
||||||
|
.item-strength-pill {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-strength-pill.strength-strong {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-strength-pill.strength-medium {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-strength-pill.strength-weak {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── TOTP live display in vault item ─────────────────────────────── */
|
/* ── TOTP live display in vault item ─────────────────────────────── */
|
||||||
.item-totp {
|
.item-totp {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -584,6 +631,7 @@ ul {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
padding: 60px 0;
|
padding: 60px 0;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
@@ -953,7 +1001,7 @@ ul {
|
|||||||
|
|
||||||
.security-summary {
|
.security-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 24px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
@@ -962,82 +1010,95 @@ ul {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sec-score-card {
|
/* ── Circular score ring ──────────────────────────────────────────── */
|
||||||
text-align: center;
|
.sec-ring-wrap {
|
||||||
|
position: relative;
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sec-score {
|
.sec-ring-svg {
|
||||||
font-size: 56px;
|
width: 100%;
|
||||||
font-weight: 700;
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-ring-score {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 800;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sec-score.score-good {
|
.sec-summary-right {
|
||||||
color: #2e7d32;
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sec-score.score-fair {
|
.sec-summary-title {
|
||||||
color: #e65100;
|
font-size: 18px;
|
||||||
}
|
|
||||||
|
|
||||||
.sec-score.score-poor {
|
|
||||||
color: #c62828;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sec-score-label {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sec-score-desc {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-text-muted);
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sec-stats {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sec-stat {
|
|
||||||
text-align: center;
|
|
||||||
background: var(--color-bg);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius);
|
|
||||||
padding: 12px 20px;
|
|
||||||
min-width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sec-stat-warn {
|
|
||||||
border-color: #ffb74d;
|
|
||||||
background: #fff8e1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sec-stat-info {
|
|
||||||
border-color: #90caf9;
|
|
||||||
background: #e3f2fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sec-stat-ok {
|
|
||||||
border-color: #a5d6a7;
|
|
||||||
background: #e8f5e9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sec-stat-num {
|
|
||||||
display: block;
|
|
||||||
font-size: 28px;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
color: var(--color-text);
|
||||||
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sec-stat-label {
|
.sec-summary-desc {
|
||||||
display: block;
|
font-size: 14px;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Progress bars ────────────────────────────────────────────────── */
|
||||||
|
.sec-bars {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-bar-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-bar-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text);
|
||||||
|
width: 130px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-bar-track {
|
||||||
|
flex: 1;
|
||||||
|
height: 6px;
|
||||||
|
background: #e5e7eb;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-bar-pct {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
margin-top: 2px;
|
width: 36px;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section cards grid ───────────────────────────────────────────── */
|
||||||
|
.sec-sections-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sec-section {
|
.sec-section {
|
||||||
@@ -1047,23 +1108,34 @@ ul {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* HIBP card spans full width above grid */
|
||||||
|
.sec-section + .sec-sections-grid {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.sec-section-header {
|
.sec-section-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
background: var(--color-bg);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sec-section-icon {
|
.sec-section-icon-wrap {
|
||||||
font-size: 20px;
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 9px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 18px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sec-section-title {
|
.sec-section-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
color: var(--color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sec-section-desc {
|
.sec-section-desc {
|
||||||
@@ -1072,6 +1144,14 @@ ul {
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sec-section-issue {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-section-issue--danger {
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
.sec-item-list {
|
.sec-item-list {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
@@ -1079,7 +1159,7 @@ ul {
|
|||||||
.sec-item {
|
.sec-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 10px;
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
@@ -1088,15 +1168,46 @@ ul {
|
|||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sec-item-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.sec-item-name {
|
.sec-item-name {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sec-item-sub {
|
.sec-item-sub {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
flex: 1;
|
}
|
||||||
|
|
||||||
|
/* Strength / status badge inside sec-item */
|
||||||
|
.sec-item-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-item-badge.strength-strong {
|
||||||
|
background: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-item-badge.strength-medium {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sec-item-badge.strength-weak {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Item Detail Modal ───────────────────────────────────────────── */
|
/* ── Item Detail Modal ───────────────────────────────────────────── */
|
||||||
@@ -1724,9 +1835,8 @@ body.is-mobile .app-layout {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sec-stats {
|
.sec-sections-grid {
|
||||||
flex-wrap: wrap;
|
grid-template-columns: 1fr;
|
||||||
gap: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
@@ -1850,24 +1960,23 @@ body.is-mobile .security-summary {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.is-mobile .sec-score {
|
body.is-mobile .sec-ring-wrap {
|
||||||
font-size: 40px;
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
body.is-mobile .sec-stats {
|
body.is-mobile .sec-ring-score {
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.is-mobile .sec-stat {
|
|
||||||
padding: 8px 12px;
|
|
||||||
min-width: 68px;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.is-mobile .sec-stat-num {
|
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.is-mobile .sec-sections-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.is-mobile #vault-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
body.is-mobile .detail-field {
|
body.is-mobile .detail-field {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|||||||
+154
-60
@@ -520,22 +520,29 @@ const Vault = (() => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Helper: password strength label & CSS class ──────────────────────────
|
||||||
|
function _passwordStrength(password) {
|
||||||
|
if (!password) return null;
|
||||||
|
const charTypes = [/[A-Z]/, /[a-z]/, /[0-9]/, /[^A-Za-z0-9]/].filter(
|
||||||
|
(r) => r.test(password),
|
||||||
|
).length;
|
||||||
|
if (password.length < 8 || charTypes < 2) return { label: "Weak", cls: "strength-weak" };
|
||||||
|
if (password.length < 12 || charTypes < 3) return { label: "Medium", cls: "strength-medium" };
|
||||||
|
return { label: "Strong", cls: "strength-strong" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper: initials avatar for a vault item name ──────────────────────────
|
||||||
|
function _itemInitials(name) {
|
||||||
|
const words = name.trim().split(/\s+/);
|
||||||
|
if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase();
|
||||||
|
return name.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
function createItemElement(item) {
|
function createItemElement(item) {
|
||||||
const li = document.createElement("li");
|
const li = document.createElement("li");
|
||||||
li.className = "vault-item";
|
li.className = "vault-item";
|
||||||
li.dataset.id = item.id;
|
li.dataset.id = item.id;
|
||||||
|
|
||||||
const iconMap = {
|
|
||||||
password: "🔑",
|
|
||||||
note: "📝",
|
|
||||||
card: "💳",
|
|
||||||
bank: "🏦",
|
|
||||||
address: "🏠",
|
|
||||||
ssn: "🪪",
|
|
||||||
passkey: "🔐",
|
|
||||||
};
|
|
||||||
const icon = iconMap[item.item_type] || "🔑";
|
|
||||||
|
|
||||||
const itemTags = item.plain?.tags || [];
|
const itemTags = item.plain?.tags || [];
|
||||||
let subText = "";
|
let subText = "";
|
||||||
if (item.plain) {
|
if (item.plain) {
|
||||||
@@ -589,14 +596,27 @@ const Vault = (() => {
|
|||||||
.map((t) => `<span class="item-tag">${escHtml(t)}</span>`)
|
.map((t) => `<span class="item-tag">${escHtml(t)}</span>`)
|
||||||
.join("");
|
.join("");
|
||||||
|
|
||||||
|
// Strength pill — only for password type
|
||||||
|
const strength =
|
||||||
|
item.item_type === "password"
|
||||||
|
? _passwordStrength(item.plain?.password)
|
||||||
|
: null;
|
||||||
|
const strengthPill = strength
|
||||||
|
? `<span class="item-strength-pill ${strength.cls}">${strength.label}</span>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// Initials avatar
|
||||||
|
const initials = _itemInitials(item.name);
|
||||||
|
|
||||||
li.innerHTML = `
|
li.innerHTML = `
|
||||||
<input type="checkbox" class="item-checkbox" data-id="${item.id}" ${_selectedIds.has(item.id) ? "checked" : ""} aria-label="Select ${escHtml(item.name)}"/>
|
<input type="checkbox" class="item-checkbox" data-id="${item.id}" ${_selectedIds.has(item.id) ? "checked" : ""} aria-label="Select ${escHtml(item.name)}"/>
|
||||||
<div class="item-icon">${icon}</div>
|
<div class="item-avatar" aria-hidden="true">${escHtml(initials)}</div>
|
||||||
<div class="item-info">
|
<div class="item-info">
|
||||||
<span class="item-name">${escHtml(item.name)}</span>
|
<span class="item-name">${escHtml(item.name)}</span>
|
||||||
<span class="item-sub">${escHtml(subText)}</span>
|
<span class="item-sub">${escHtml(subText)}</span>
|
||||||
${tagBadges ? `<div class="item-tags">${tagBadges}</div>` : ""}
|
${tagBadges ? `<div class="item-tags">${tagBadges}</div>` : ""}
|
||||||
${showTotp ? `<span class="item-totp" id="totp-display-${item.id}"><span class="totp-code">······</span><span class="totp-timer"></span></span>` : ""}
|
${showTotp ? `<span class="item-totp" id="totp-display-${item.id}"><span class="totp-code">······</span><span class="totp-timer"></span></span>` : ""}
|
||||||
|
${strengthPill}
|
||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
${showLaunch ? `<button class="btn-icon" title="Open URL" data-action="launch">🌐</button>` : ""}
|
${showLaunch ? `<button class="btn-icon" title="Open URL" data-action="launch">🌐</button>` : ""}
|
||||||
@@ -1140,32 +1160,84 @@ const Vault = (() => {
|
|||||||
const label =
|
const label =
|
||||||
score >= 80 ? "Good" : score >= 50 ? "Fair" : "Needs attention";
|
score >= 80 ? "Good" : score >= 50 ? "Fair" : "Needs attention";
|
||||||
|
|
||||||
|
// ── Build new summary: circular ring + title + progress bars ────────────
|
||||||
|
const strongPct = Math.round(((total - weak.length) / total) * 100);
|
||||||
|
const uniqueIds = new Set(reused.map((i) => i.id));
|
||||||
|
const reusedCount = uniqueIds.size;
|
||||||
|
const uniquePct = Math.round(((total - reusedCount) / total) * 100);
|
||||||
|
// 2FA pct: items with totp stored vs all password items
|
||||||
|
const totpCount = pwItems.filter((i) =>
|
||||||
|
!!extractTotpSecret(i.plain?.totp_uri),
|
||||||
|
).length;
|
||||||
|
const twoFAPct = Math.round((totpCount / total) * 100);
|
||||||
|
|
||||||
|
// SVG ring math: r=42, circumference = 2*π*42 ≈ 264
|
||||||
|
const CIRC = 264;
|
||||||
|
const ringFill = Math.round((score / 100) * CIRC);
|
||||||
|
const ringColor =
|
||||||
|
score >= 80 ? "#2e7d32" : score >= 50 ? "#e65100" : "#c62828";
|
||||||
|
const scoreLabel =
|
||||||
|
score >= 80
|
||||||
|
? "Good — keep improving"
|
||||||
|
: score >= 50
|
||||||
|
? "Fair — some issues to fix"
|
||||||
|
: "Needs attention";
|
||||||
|
const scoreDesc =
|
||||||
|
score >= 80
|
||||||
|
? `Your vault is mostly secure. ${weak.length + reusedCount} issue${weak.length + reusedCount !== 1 ? "s" : ""} need attention.`
|
||||||
|
: score >= 50
|
||||||
|
? "Several passwords need your attention."
|
||||||
|
: "Multiple security issues detected.";
|
||||||
|
|
||||||
|
const makeBar = (label, pct, color) => `
|
||||||
|
<div class="sec-bar-row">
|
||||||
|
<span class="sec-bar-label">${label}</span>
|
||||||
|
<div class="sec-bar-track"><div class="sec-bar-fill" style="width:${pct}%;background:${color};"></div></div>
|
||||||
|
<span class="sec-bar-pct">${pct}%</span>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
summaryEl.innerHTML = `
|
summaryEl.innerHTML = `
|
||||||
<div class="sec-score-card">
|
<div class="sec-ring-wrap">
|
||||||
<div class="sec-score ${cls}">${score}</div>
|
<svg viewBox="0 0 100 100" class="sec-ring-svg" aria-hidden="true">
|
||||||
<div class="sec-score-label">${label}</div>
|
<circle cx="50" cy="50" r="42" fill="none" stroke="#e5e7eb" stroke-width="8"/>
|
||||||
<div class="sec-score-desc">Based on ${total} password${total !== 1 ? "s" : ""}</div>
|
<circle cx="50" cy="50" r="42" fill="none" stroke="${ringColor}" stroke-width="8"
|
||||||
|
stroke-dasharray="${ringFill} ${CIRC}" stroke-dashoffset="66"
|
||||||
|
stroke-linecap="round" transform="rotate(-90 50 50)"/>
|
||||||
|
</svg>
|
||||||
|
<div class="sec-ring-score" style="color:${ringColor};">${score}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sec-summary-right">
|
||||||
|
<div class="sec-summary-title">${escHtml(scoreLabel)}</div>
|
||||||
|
<div class="sec-summary-desc">${escHtml(scoreDesc)}</div>
|
||||||
|
<div class="sec-bars">
|
||||||
|
${makeBar("Strong passwords", strongPct, "#2e7d32")}
|
||||||
|
${makeBar("Unique passwords", uniquePct, "#1a73e8")}
|
||||||
|
${makeBar("2FA enabled", twoFAPct, "#e65100")}
|
||||||
</div>
|
</div>
|
||||||
<div class="sec-stats">
|
|
||||||
<div class="sec-stat ${weak.length ? "sec-stat-warn" : "sec-stat-ok"}"><span class="sec-stat-num">${weak.length}</span><span class="sec-stat-label">Weak</span></div>
|
|
||||||
<div class="sec-stat ${reused.length ? "sec-stat-warn" : "sec-stat-ok"}"><span class="sec-stat-num">${reused.length}</span><span class="sec-stat-label">Reused</span></div>
|
|
||||||
<div class="sec-stat ${old.length ? "sec-stat-info" : "sec-stat-ok"}"><span class="sec-stat-num">${old.length}</span><span class="sec-stat-label">Old & Weak</span></div>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
sectionsEl.innerHTML = "";
|
sectionsEl.innerHTML = "";
|
||||||
const makeSection = (title, icon, items, desc, renderItem) => {
|
|
||||||
if (!items.length) return;
|
// ── Section card builder ──────────────────────────────────────────────────
|
||||||
|
// Returns a div card element. renderItem(i) returns an HTML string for each li.
|
||||||
|
const makeSection = (title, icon, iconBg, items, desc, renderItem) => {
|
||||||
|
if (!items.length) return null;
|
||||||
const sec = document.createElement("div");
|
const sec = document.createElement("div");
|
||||||
sec.className = "sec-section";
|
sec.className = "sec-section";
|
||||||
const defaultRender = (i) => `<li class="sec-item">
|
const defaultRender = (i) => `
|
||||||
|
<li class="sec-item">
|
||||||
|
<span class="sec-item-icon">${icon}</span>
|
||||||
<span class="sec-item-name">${escHtml(i.name)}</span>
|
<span class="sec-item-name">${escHtml(i.name)}</span>
|
||||||
<span class="sec-item-sub">${escHtml(i.plain?.username || "")}</span>
|
|
||||||
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Edit</button>
|
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Edit</button>
|
||||||
</li>`;
|
</li>`;
|
||||||
const renderFn = renderItem || defaultRender;
|
const renderFn = renderItem || defaultRender;
|
||||||
sec.innerHTML = `
|
sec.innerHTML = `
|
||||||
<div class="sec-section-header"><span class="sec-section-icon">${icon}</span>
|
<div class="sec-section-header">
|
||||||
<div><div class="sec-section-title">${title} (${items.length})</div><div class="sec-section-desc">${desc}</div></div>
|
<div class="sec-section-icon-wrap" style="background:${iconBg};">${icon}</div>
|
||||||
|
<div>
|
||||||
|
<div class="sec-section-title">${escHtml(title)}</div>
|
||||||
|
<div class="sec-section-desc sec-section-issue">${escHtml(items.length)} ${escHtml(desc)}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="sec-item-list">
|
<ul class="sec-item-list">
|
||||||
${items.map(renderFn).join("")}
|
${items.map(renderFn).join("")}
|
||||||
@@ -1181,78 +1253,98 @@ const Vault = (() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
sectionsEl.appendChild(sec);
|
return sec;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Build section cards ───────────────────────────────────────────────────
|
||||||
|
const noTotp = pwItems.filter(
|
||||||
|
(i) => i.plain?.url && !extractTotpSecret(i.plain?.totp_uri),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sections rendered in pairs into a 2-col grid
|
||||||
|
const sectionDefs = [
|
||||||
|
// HIBP section will be inserted asynchronously — placeholder first
|
||||||
|
null,
|
||||||
makeSection(
|
makeSection(
|
||||||
"Weak Passwords",
|
"Weak Passwords",
|
||||||
"⚠️",
|
"⚠️",
|
||||||
|
"#fff3cd",
|
||||||
weak,
|
weak,
|
||||||
"Short or low-complexity passwords.",
|
"password(s) need strengthening",
|
||||||
);
|
(i) => {
|
||||||
|
const s = _passwordStrength(i.plain?.password);
|
||||||
|
return `<li class="sec-item">
|
||||||
|
<span class="sec-item-icon">🔑</span>
|
||||||
|
<span class="sec-item-name">${escHtml(i.name)}</span>
|
||||||
|
<span class="sec-item-badge ${s ? s.cls : ""}">${s ? s.label : "Weak"}</span>
|
||||||
|
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Edit</button>
|
||||||
|
</li>`;
|
||||||
|
},
|
||||||
|
),
|
||||||
makeSection(
|
makeSection(
|
||||||
"Reused Passwords",
|
"Reused Passwords",
|
||||||
"♻️",
|
"♻️",
|
||||||
|
"#e3f2fd",
|
||||||
[...new Set(reused.map((i) => i.id))]
|
[...new Set(reused.map((i) => i.id))]
|
||||||
.map((id) => _items.find((i) => i.id === id))
|
.map((id) => _items.find((i) => i.id === id))
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
"Same password used on multiple sites.",
|
"password(s) reused across sites",
|
||||||
);
|
),
|
||||||
makeSection(
|
makeSection(
|
||||||
"Old Passwords",
|
"Old Passwords",
|
||||||
"🕐",
|
"🕐",
|
||||||
|
"#f3e8ff",
|
||||||
old,
|
old,
|
||||||
"Weak or reused passwords not changed in over 180 days.",
|
"password(s) not changed in 180+ days",
|
||||||
(i) => {
|
(i) => {
|
||||||
const ref = i.plain?.password_changed_at || i.created_at;
|
const ref = i.plain?.password_changed_at || i.created_at;
|
||||||
const daysAgo = ref
|
const daysAgo = ref
|
||||||
? Math.floor((Date.now() - new Date(ref).getTime()) / 86400000)
|
? Math.floor((Date.now() - new Date(ref).getTime()) / 86400000)
|
||||||
: null;
|
: null;
|
||||||
const ageLabel = daysAgo !== null
|
const ageLabel =
|
||||||
|
daysAgo !== null
|
||||||
? `Last changed ${daysAgo} day${daysAgo !== 1 ? "s" : ""} ago`
|
? `Last changed ${daysAgo} day${daysAgo !== 1 ? "s" : ""} ago`
|
||||||
: "Age unknown";
|
: "Age unknown";
|
||||||
return `<li class="sec-item">
|
return `<li class="sec-item">
|
||||||
|
<span class="sec-item-icon">🕐</span>
|
||||||
<span class="sec-item-name">${escHtml(i.name)}</span>
|
<span class="sec-item-name">${escHtml(i.name)}</span>
|
||||||
<span class="sec-item-sub">${escHtml(ageLabel)}</span>
|
<span class="sec-item-sub">${escHtml(ageLabel)}</span>
|
||||||
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Edit</button>
|
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Edit</button>
|
||||||
</li>`;
|
</li>`;
|
||||||
},
|
},
|
||||||
);
|
),
|
||||||
|
|
||||||
// ── Missing 2FA warning ──────────────────────────────────────────────────
|
|
||||||
// Flag password items that have a URL but no TOTP URI saved.
|
|
||||||
// These accounts likely support 2FA but the user hasn't stored it.
|
|
||||||
const noTotp = pwItems.filter(
|
|
||||||
(i) => i.plain?.url && !extractTotpSecret(i.plain?.totp_uri),
|
|
||||||
);
|
|
||||||
makeSection(
|
makeSection(
|
||||||
"No 2FA Saved",
|
"No 2FA Saved",
|
||||||
"🔓",
|
"🔓",
|
||||||
|
"#e8f5e9",
|
||||||
noTotp,
|
noTotp,
|
||||||
"These accounts may support two-factor authentication but have no TOTP code stored.",
|
"account(s) may support 2FA but none stored",
|
||||||
);
|
),
|
||||||
|
].filter((s) => s !== null);
|
||||||
|
|
||||||
|
// Render all non-HIBP sections into grid
|
||||||
|
const grid = document.createElement("div");
|
||||||
|
grid.className = "sec-sections-grid";
|
||||||
|
sectionDefs.forEach((s) => grid.appendChild(s));
|
||||||
|
sectionsEl.appendChild(grid);
|
||||||
|
|
||||||
// ── HaveIBeenPwned breach check ──────────────────────────────────────────
|
// ── HaveIBeenPwned breach check ──────────────────────────────────────────
|
||||||
// Use cached results from the background check when available — avoids
|
const hibpCard = document.createElement("div");
|
||||||
// re-querying HIBP every time the user opens the Security tab.
|
hibpCard.className = "sec-section sec-section-hibp-loading";
|
||||||
const hibpSection = document.createElement("div");
|
hibpCard.innerHTML = `
|
||||||
hibpSection.className = "sec-section";
|
|
||||||
hibpSection.innerHTML = `
|
|
||||||
<div class="sec-section-header">
|
<div class="sec-section-header">
|
||||||
<span class="sec-section-icon">🔓</span>
|
<div class="sec-section-icon-wrap" style="background:#fde8e8;">🚨</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="sec-section-title">Checking for known breaches…</div>
|
<div class="sec-section-title">Checking for known breaches…</div>
|
||||||
<div class="sec-section-desc">Querying HaveIBeenPwned (k-anonymity — your passwords are never sent).</div>
|
<div class="sec-section-desc">Querying HaveIBeenPwned (k-anonymity — your passwords are never sent).</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
sectionsEl.appendChild(hibpSection);
|
sectionsEl.insertBefore(hibpCard, grid);
|
||||||
|
|
||||||
// Use cached results if available, otherwise run fresh checks.
|
|
||||||
let hibpResults;
|
let hibpResults;
|
||||||
if (_healthCache?.hibpResults) {
|
if (_healthCache?.hibpResults) {
|
||||||
hibpResults = _healthCache.hibpResults;
|
hibpResults = _healthCache.hibpResults;
|
||||||
} else {
|
} else {
|
||||||
// Run all HIBP checks in parallel — k-anonymity: only 5-char SHA-1 prefix sent.
|
|
||||||
hibpResults = await Promise.all(
|
hibpResults = await Promise.all(
|
||||||
pwItems.map(async (item) => ({
|
pwItems.map(async (item) => ({
|
||||||
item,
|
item,
|
||||||
@@ -1263,21 +1355,21 @@ const Vault = (() => {
|
|||||||
const breached = hibpResults.filter((r) => r.count > 0).map((r) => r.item);
|
const breached = hibpResults.filter((r) => r.count > 0).map((r) => r.item);
|
||||||
|
|
||||||
if (!breached.length) {
|
if (!breached.length) {
|
||||||
hibpSection.innerHTML = `
|
hibpCard.innerHTML = `
|
||||||
<div class="sec-section-header">
|
<div class="sec-section-header">
|
||||||
<span class="sec-section-icon">✅</span>
|
<div class="sec-section-icon-wrap" style="background:#e8f5e9;">✅</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="sec-section-title">No known breaches</div>
|
<div class="sec-section-title">No known breaches</div>
|
||||||
<div class="sec-section-desc">None of your passwords appeared in known data breaches (via HaveIBeenPwned).</div>
|
<div class="sec-section-desc">None of your passwords appeared in known data breaches (via HaveIBeenPwned).</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
} else {
|
} else {
|
||||||
hibpSection.innerHTML = `
|
hibpCard.innerHTML = `
|
||||||
<div class="sec-section-header">
|
<div class="sec-section-header">
|
||||||
<span class="sec-section-icon">🔓</span>
|
<div class="sec-section-icon-wrap" style="background:#fde8e8;">🚨</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="sec-section-title">Breached Passwords (${breached.length})</div>
|
<div class="sec-section-title">Breached Passwords</div>
|
||||||
<div class="sec-section-desc">These passwords appeared in known data breaches. Change them immediately.</div>
|
<div class="sec-section-desc sec-section-issue sec-section-issue--danger">${breached.length} password${breached.length !== 1 ? "s" : ""} found in data breaches</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul class="sec-item-list">
|
<ul class="sec-item-list">
|
||||||
@@ -1286,14 +1378,15 @@ const Vault = (() => {
|
|||||||
const count =
|
const count =
|
||||||
hibpResults.find((r) => r.item.id === i.id)?.count || 0;
|
hibpResults.find((r) => r.item.id === i.id)?.count || 0;
|
||||||
return `<li class="sec-item">
|
return `<li class="sec-item">
|
||||||
|
<span class="sec-item-icon">🛒</span>
|
||||||
<span class="sec-item-name">${escHtml(i.name)}</span>
|
<span class="sec-item-name">${escHtml(i.name)}</span>
|
||||||
<span class="sec-item-sub">${escHtml(i.plain?.username || "")} — seen ${count.toLocaleString()} time${count !== 1 ? "s" : ""} in breaches</span>
|
<span class="sec-item-badge strength-weak">Breached</span>
|
||||||
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Change</button>
|
<button class="btn-secondary btn-sm" data-sec-edit="${i.id}">Change</button>
|
||||||
</li>`;
|
</li>`;
|
||||||
})
|
})
|
||||||
.join("")}
|
.join("")}
|
||||||
</ul>`;
|
</ul>`;
|
||||||
hibpSection.querySelectorAll("[data-sec-edit]").forEach((btn) => {
|
hibpCard.querySelectorAll("[data-sec-edit]").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
const item = _items.find(
|
const item = _items.find(
|
||||||
(it) => it.id === parseInt(btn.dataset.secEdit),
|
(it) => it.id === parseInt(btn.dataset.secEdit),
|
||||||
@@ -1305,6 +1398,7 @@ const Vault = (() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
hibpCard.classList.remove("sec-section-hibp-loading");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Import / Export View ─────────────────────────────────────────────────
|
// ── Import / Export View ─────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user