04/17/2026 update launch button, password generation, and password checker
This commit is contained in:
@@ -108,6 +108,15 @@ ul { list-style: none; }
|
|||||||
.notice-success { background: #e8f5e9; border: 1px solid #a5d6a7; color: #2e7d32; border-radius: var(--radius); padding: 10px 14px; margin-bottom: 16px; font-size: 13px; }
|
.notice-success { background: #e8f5e9; border: 1px solid #a5d6a7; color: #2e7d32; border-radius: var(--radius); padding: 10px 14px; margin-bottom: 16px; font-size: 13px; }
|
||||||
.notice-info { background: #e3f2fd; border: 1px solid #90caf9; color: #1565c0; border-radius: var(--radius); padding: 10px 14px; margin-bottom: 16px; font-size: 13px; }
|
.notice-info { background: #e3f2fd; border: 1px solid #90caf9; color: #1565c0; border-radius: var(--radius); padding: 10px 14px; margin-bottom: 16px; font-size: 13px; }
|
||||||
|
|
||||||
|
/* ── Password Generator modal ────────────────────────────────────────── */
|
||||||
|
.gen-opt { display: flex; align-items: center; gap: 6px; cursor: pointer; font-size: 13px; }
|
||||||
|
.gen-opt input[type="checkbox"] { width: 15px; height: 15px; accent-color: var(--color-primary); cursor: pointer; }
|
||||||
|
#gen-output { letter-spacing: .5px; font-family: 'Courier New', monospace; }
|
||||||
|
|
||||||
|
/* Launch (open URL) button — distinct colour so it reads as navigation */
|
||||||
|
.btn-icon[data-action="launch"] { color: #1a73e8; }
|
||||||
|
.btn-icon[data-action="launch"]:hover { background: #e8f0fe; color: #1558b0; }
|
||||||
|
|
||||||
/* Password strength */
|
/* Password strength */
|
||||||
.password-strength { margin-top: 6px; font-size: 12px; font-weight: 500; }
|
.password-strength { margin-top: 6px; font-size: 12px; font-weight: 500; }
|
||||||
.strength-1 { color: #c62828; }
|
.strength-1 { color: #c62828; }
|
||||||
|
|||||||
+205
-2
@@ -255,6 +255,7 @@ const Vault = (() => {
|
|||||||
|
|
||||||
const showCopyUser = item.item_type === 'password';
|
const showCopyUser = item.item_type === 'password';
|
||||||
const showCopyPass = ['password','card','bank','ssn'].includes(item.item_type);
|
const showCopyPass = ['password','card','bank','ssn'].includes(item.item_type);
|
||||||
|
const showLaunch = item.item_type === 'password' && item.plain?.url;
|
||||||
|
|
||||||
li.innerHTML = `
|
li.innerHTML = `
|
||||||
<div class="item-icon">${icon}</div>
|
<div class="item-icon">${icon}</div>
|
||||||
@@ -263,12 +264,21 @@ const Vault = (() => {
|
|||||||
<span class="item-sub">${escHtml(subText)}</span>
|
<span class="item-sub">${escHtml(subText)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
|
${showLaunch ? `<button class="btn-icon" title="Open URL" data-action="launch">🌐</button>` : ''}
|
||||||
${showCopyUser ? `<button class="btn-icon" title="Copy username" data-action="copy-user">👤</button>` : ''}
|
${showCopyUser ? `<button class="btn-icon" title="Copy username" data-action="copy-user">👤</button>` : ''}
|
||||||
${showCopyPass ? `<button class="btn-icon" title="Copy secret" data-action="copy-pass">📋</button>` : ''}
|
${showCopyPass ? `<button class="btn-icon" title="Copy secret" data-action="copy-pass">📋</button>` : ''}
|
||||||
<button class="btn-icon" title="Edit" data-action="edit">✏️</button>
|
<button class="btn-icon" title="Edit" data-action="edit">✏️</button>
|
||||||
<button class="btn-icon" title="Delete" data-action="delete">🗑️</button>
|
<button class="btn-icon" title="Delete" data-action="delete">🗑️</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
if (showLaunch) {
|
||||||
|
li.querySelector('[data-action="launch"]').addEventListener('click', e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
let url = item.plain?.url || '';
|
||||||
|
if (url && !/^https?:\/\//i.test(url)) url = 'https://' + url;
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
});
|
||||||
|
}
|
||||||
if (showCopyUser) {
|
if (showCopyUser) {
|
||||||
li.querySelector('[data-action="copy-user"]').addEventListener('click', e => {
|
li.querySelector('[data-action="copy-user"]').addEventListener('click', e => {
|
||||||
e.stopPropagation(); copyToClipboard(item.plain?.username || '', 'Username copied');
|
e.stopPropagation(); copyToClipboard(item.plain?.username || '', 'Username copied');
|
||||||
@@ -853,6 +863,191 @@ const Vault = (() => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Password Generator ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const GEN_CHARSETS = {
|
||||||
|
upper: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
||||||
|
lower: 'abcdefghijklmnopqrstuvwxyz',
|
||||||
|
digits: '0123456789',
|
||||||
|
symbols: '!@#$%^&*()-_=+[]{}|;:,.<>?',
|
||||||
|
};
|
||||||
|
|
||||||
|
function generatePassword(length = 20, opts = { upper: true, lower: true, digits: true, symbols: true }) {
|
||||||
|
let pool = '';
|
||||||
|
const required = [];
|
||||||
|
if (opts.upper) { pool += GEN_CHARSETS.upper; required.push(GEN_CHARSETS.upper); }
|
||||||
|
if (opts.lower) { pool += GEN_CHARSETS.lower; required.push(GEN_CHARSETS.lower); }
|
||||||
|
if (opts.digits) { pool += GEN_CHARSETS.digits; required.push(GEN_CHARSETS.digits); }
|
||||||
|
if (opts.symbols) { pool += GEN_CHARSETS.symbols; required.push(GEN_CHARSETS.symbols); }
|
||||||
|
if (!pool) pool = GEN_CHARSETS.lower;
|
||||||
|
|
||||||
|
const arr = new Uint32Array(length + required.length);
|
||||||
|
window.crypto.getRandomValues(arr);
|
||||||
|
|
||||||
|
// Guarantee at least one char from each required charset
|
||||||
|
const chars = required.map((cs, i) => cs[arr[i] % cs.length]);
|
||||||
|
for (let i = required.length; i < length + required.length; i++) {
|
||||||
|
chars.push(pool[arr[i] % pool.length]);
|
||||||
|
}
|
||||||
|
// Fisher-Yates shuffle using crypto random
|
||||||
|
const shuffle = new Uint32Array(chars.length);
|
||||||
|
window.crypto.getRandomValues(shuffle);
|
||||||
|
for (let i = chars.length - 1; i > 0; i--) {
|
||||||
|
const j = shuffle[i] % (i + 1);
|
||||||
|
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||||
|
}
|
||||||
|
return chars.slice(0, length).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function scorePassword(pw) {
|
||||||
|
if (!pw) return { score: 0, label: '', cls: '' };
|
||||||
|
let score = 0;
|
||||||
|
if (pw.length >= 8) score++;
|
||||||
|
if (pw.length >= 12) score++;
|
||||||
|
if (pw.length >= 16) score++;
|
||||||
|
if (/[A-Z]/.test(pw)) score++;
|
||||||
|
if (/[a-z]/.test(pw)) score++;
|
||||||
|
if (/[0-9]/.test(pw)) score++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(pw)) score++;
|
||||||
|
// clamp to 5 bands
|
||||||
|
const band = Math.min(5, Math.ceil(score / 7 * 5));
|
||||||
|
const labels = ['', 'Very Weak', 'Weak', 'Fair', 'Strong', 'Very Strong'];
|
||||||
|
const clses = ['', 'strength-1','strength-2','strength-3','strength-4','strength-5'];
|
||||||
|
return { score: band, label: labels[band], cls: clses[band] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach strength meter + generate button to the password field in the item modal
|
||||||
|
function initPasswordFieldEnhancements() {
|
||||||
|
const pwInput = document.getElementById('field-password');
|
||||||
|
if (!pwInput) return;
|
||||||
|
|
||||||
|
// Strength bar below the password field
|
||||||
|
if (!document.getElementById('field-password-strength')) {
|
||||||
|
const bar = document.createElement('div');
|
||||||
|
bar.id = 'field-password-strength';
|
||||||
|
bar.className = 'password-strength';
|
||||||
|
pwInput.closest('.input-with-toggle').insertAdjacentElement('afterend', bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
pwInput.addEventListener('input', () => {
|
||||||
|
const { label, cls } = scorePassword(pwInput.value);
|
||||||
|
const bar = document.getElementById('field-password-strength');
|
||||||
|
bar.textContent = pwInput.value ? label : '';
|
||||||
|
bar.className = 'password-strength ' + (pwInput.value ? cls : '');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate button — add only once
|
||||||
|
if (!document.getElementById('btn-generate-pass')) {
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.id = 'btn-generate-pass';
|
||||||
|
btn.className = 'btn-secondary btn-sm';
|
||||||
|
btn.textContent = '⚡ Generate';
|
||||||
|
btn.style.marginTop = '6px';
|
||||||
|
pwInput.closest('.form-group').appendChild(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-generate-pass').onclick = () => {
|
||||||
|
const pw = generatePassword(20);
|
||||||
|
pwInput.value = pw;
|
||||||
|
pwInput.type = 'text'; // reveal so user can see what was generated
|
||||||
|
pwInput.dispatchEvent(new Event('input')); // trigger strength bar
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPasswordGeneratorModal() {
|
||||||
|
// Build modal content dynamically — keeps the HTML lean
|
||||||
|
let modal = document.getElementById('gen-modal');
|
||||||
|
if (!modal) {
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'gen-modal';
|
||||||
|
modal.className = 'modal-overlay';
|
||||||
|
modal.setAttribute('role', 'dialog');
|
||||||
|
modal.setAttribute('aria-modal', 'true');
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal" style="max-width:420px">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>⚡ Password Generator</h3>
|
||||||
|
<button class="btn-icon btn-close" id="btn-close-gen-modal" aria-label="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="margin-bottom:8px">
|
||||||
|
<label>Length: <strong id="gen-length-display">20</strong></label>
|
||||||
|
<input type="range" id="gen-length" min="8" max="64" value="20" style="width:100%;margin-top:4px">
|
||||||
|
</div>
|
||||||
|
<div class="gen-options" style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:16px">
|
||||||
|
<label class="gen-opt"><input type="checkbox" id="gen-upper" checked> Uppercase</label>
|
||||||
|
<label class="gen-opt"><input type="checkbox" id="gen-lower" checked> Lowercase</label>
|
||||||
|
<label class="gen-opt"><input type="checkbox" id="gen-digits" checked> Numbers</label>
|
||||||
|
<label class="gen-opt"><input type="checkbox" id="gen-symbols" checked> Symbols</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Generated Password</label>
|
||||||
|
<div class="input-with-toggle" style="margin-bottom:4px">
|
||||||
|
<input type="text" id="gen-output" readonly style="font-family:monospace;letter-spacing:.5px;padding-right:40px">
|
||||||
|
<button type="button" class="btn-show-pass" id="btn-gen-copy" title="Copy" style="font-size:14px">📋</button>
|
||||||
|
</div>
|
||||||
|
<div id="gen-strength" class="password-strength"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" class="btn-secondary" id="btn-close-gen-modal2">Close</button>
|
||||||
|
<button type="button" class="btn-primary" id="btn-regenerate">↺ Regenerate</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
const lengthSlider = document.getElementById('gen-length');
|
||||||
|
const lengthDisplay = document.getElementById('gen-length-display');
|
||||||
|
const output = document.getElementById('gen-output');
|
||||||
|
const strengthEl = document.getElementById('gen-strength');
|
||||||
|
|
||||||
|
function refreshGen() {
|
||||||
|
const pw = generatePassword(parseInt(lengthSlider.value), {
|
||||||
|
upper: document.getElementById('gen-upper').checked,
|
||||||
|
lower: document.getElementById('gen-lower').checked,
|
||||||
|
digits: document.getElementById('gen-digits').checked,
|
||||||
|
symbols: document.getElementById('gen-symbols').checked,
|
||||||
|
});
|
||||||
|
output.value = pw;
|
||||||
|
const { label, cls } = scorePassword(pw);
|
||||||
|
strengthEl.textContent = label;
|
||||||
|
strengthEl.className = 'password-strength ' + cls;
|
||||||
|
}
|
||||||
|
|
||||||
|
lengthSlider.addEventListener('input', () => {
|
||||||
|
lengthDisplay.textContent = lengthSlider.value;
|
||||||
|
refreshGen();
|
||||||
|
});
|
||||||
|
['gen-upper','gen-lower','gen-digits','gen-symbols'].forEach(id => {
|
||||||
|
document.getElementById(id).addEventListener('change', refreshGen);
|
||||||
|
});
|
||||||
|
document.getElementById('btn-regenerate').addEventListener('click', refreshGen);
|
||||||
|
document.getElementById('btn-gen-copy').addEventListener('click', () => {
|
||||||
|
navigator.clipboard.writeText(output.value).then(() => showToast('Password copied'));
|
||||||
|
});
|
||||||
|
const closeGen = () => modal.classList.remove('open');
|
||||||
|
document.getElementById('btn-close-gen-modal').addEventListener('click', closeGen);
|
||||||
|
document.getElementById('btn-close-gen-modal2').addEventListener('click', closeGen);
|
||||||
|
modal.addEventListener('click', e => { if (e.target === modal) closeGen(); });
|
||||||
|
|
||||||
|
refreshGen();
|
||||||
|
}
|
||||||
|
modal.classList.add('open');
|
||||||
|
// Regenerate fresh password each time modal opens
|
||||||
|
const lengthSlider = document.getElementById('gen-length');
|
||||||
|
document.getElementById('gen-length-display').textContent = lengthSlider.value;
|
||||||
|
const pw = generatePassword(parseInt(lengthSlider.value), {
|
||||||
|
upper: document.getElementById('gen-upper').checked,
|
||||||
|
lower: document.getElementById('gen-lower').checked,
|
||||||
|
digits: document.getElementById('gen-digits').checked,
|
||||||
|
symbols: document.getElementById('gen-symbols').checked,
|
||||||
|
});
|
||||||
|
document.getElementById('gen-output').value = pw;
|
||||||
|
const { label, cls } = scorePassword(pw);
|
||||||
|
const strengthEl = document.getElementById('gen-strength');
|
||||||
|
strengthEl.textContent = label;
|
||||||
|
strengthEl.className = 'password-strength ' + cls;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Account Settings ──────────────────────────────────────────────────────
|
// ── Account Settings ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
let _pendingMfaSecret = null;
|
let _pendingMfaSecret = null;
|
||||||
@@ -1123,6 +1318,10 @@ const Vault = (() => {
|
|||||||
}
|
}
|
||||||
document.getElementById('item-modal').classList.add('open');
|
document.getElementById('item-modal').classList.add('open');
|
||||||
document.getElementById('field-name').focus();
|
document.getElementById('field-name').focus();
|
||||||
|
if ((mode === 'add' && (document.getElementById('field-type').value || 'password') === 'password') ||
|
||||||
|
(mode === 'edit' && (item?.item_type || 'password') === 'password')) {
|
||||||
|
initPasswordFieldEnhancements();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setVal(id, val) { const el = document.getElementById(id); if (el) el.value = val || ''; }
|
function setVal(id, val) { const el = document.getElementById(id); if (el) el.value = val || ''; }
|
||||||
@@ -1303,7 +1502,10 @@ const Vault = (() => {
|
|||||||
document.getElementById('btn-logout')?.addEventListener('click', handleLogout);
|
document.getElementById('btn-logout')?.addEventListener('click', handleLogout);
|
||||||
|
|
||||||
document.getElementById('sort-select')?.addEventListener('change', e => { _sortOrder = e.target.value; applyCurrentFilter(); });
|
document.getElementById('sort-select')?.addEventListener('change', e => { _sortOrder = e.target.value; applyCurrentFilter(); });
|
||||||
document.getElementById('field-type')?.addEventListener('change', e => switchModalType(e.target.value));
|
document.getElementById('field-type')?.addEventListener('change', e => {
|
||||||
|
switchModalType(e.target.value);
|
||||||
|
if (e.target.value === 'password') initPasswordFieldEnhancements();
|
||||||
|
});
|
||||||
document.getElementById('btn-toggle-pass')?.addEventListener('click', () => { const el = document.getElementById('field-password'); el.type = el.type === 'password' ? 'text' : 'password'; });
|
document.getElementById('btn-toggle-pass')?.addEventListener('click', () => { const el = document.getElementById('field-password'); el.type = el.type === 'password' ? 'text' : 'password'; });
|
||||||
document.getElementById('btn-toggle-ssn')?.addEventListener('click', () => { const el = document.getElementById('field-ssn-number'); el.type = el.type === 'password' ? 'text' : 'password'; });
|
document.getElementById('btn-toggle-ssn')?.addEventListener('click', () => { const el = document.getElementById('field-ssn-number'); el.type = el.type === 'password' ? 'text' : 'password'; });
|
||||||
|
|
||||||
@@ -1317,6 +1519,7 @@ const Vault = (() => {
|
|||||||
document.getElementById('sidebar-security')?.addEventListener('click', () => switchView('security'));
|
document.getElementById('sidebar-security')?.addEventListener('click', () => switchView('security'));
|
||||||
document.getElementById('sidebar-sharing')?.addEventListener('click', () => switchView('sharing'));
|
document.getElementById('sidebar-sharing')?.addEventListener('click', () => switchView('sharing'));
|
||||||
document.getElementById('sidebar-emergency')?.addEventListener('click', () => switchView('emergency'));
|
document.getElementById('sidebar-emergency')?.addEventListener('click', () => switchView('emergency'));
|
||||||
|
document.getElementById('sidebar-generator')?.addEventListener('click', () => openPasswordGeneratorModal());
|
||||||
|
|
||||||
document.querySelectorAll('[data-type-filter]').forEach(el => {
|
document.querySelectorAll('[data-type-filter]').forEach(el => {
|
||||||
el.addEventListener('click', () => {
|
el.addEventListener('click', () => {
|
||||||
@@ -1332,7 +1535,7 @@ const Vault = (() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('item-modal')?.addEventListener('click', e => { if (e.target.id === 'item-modal') closeModal(); });
|
document.getElementById('item-modal')?.addEventListener('click', e => { if (e.target.id === 'item-modal') closeModal(); });
|
||||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeModal(); closeModal('share-modal'); closeModal('emergency-modal'); closeModal('settings-modal'); closeModal('detail-modal'); } });
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') { closeModal(); closeModal('share-modal'); closeModal('emergency-modal'); closeModal('settings-modal'); closeModal('detail-modal'); closeModal('gen-modal'); } });
|
||||||
|
|
||||||
// Detail modal (shared item view)
|
// Detail modal (shared item view)
|
||||||
const closeDetailModal = () => closeModal('detail-modal');
|
const closeDetailModal = () => closeModal('detail-modal');
|
||||||
|
|||||||
@@ -46,6 +46,9 @@
|
|||||||
<li class="sidebar-item" id="sidebar-emergency" data-view="emergency">
|
<li class="sidebar-item" id="sidebar-emergency" data-view="emergency">
|
||||||
<span class="sidebar-icon">🚨</span> <span class="sidebar-label">Emergency Access</span>
|
<span class="sidebar-icon">🚨</span> <span class="sidebar-label">Emergency Access</span>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="sidebar-item" id="sidebar-generator">
|
||||||
|
<span class="sidebar-icon">⚡</span> <span class="sidebar-label">Password Generator</span>
|
||||||
|
</li>
|
||||||
<li class="sidebar-section-header">
|
<li class="sidebar-section-header">
|
||||||
Folders
|
Folders
|
||||||
<button class="btn-new-folder" id="btn-new-folder" title="New folder">+</button>
|
<button class="btn-new-folder" id="btn-new-folder" title="New folder">+</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user