04/17/2026 update launch button, password generation, and password checker

This commit is contained in:
2026-04-17 16:57:34 -04:00
parent 6f4a7e47da
commit 802e29857f
3 changed files with 217 additions and 2 deletions
+9
View File
@@ -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
View File
@@ -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');
+3
View File
@@ -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>