diff --git a/app/static/css/app.css b/app/static/css/app.css index 85c32bb..1a1856a 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -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-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 { margin-top: 6px; font-size: 12px; font-weight: 500; } .strength-1 { color: #c62828; } diff --git a/app/static/js/vault.js b/app/static/js/vault.js index 988fbc5..85edd6e 100644 --- a/app/static/js/vault.js +++ b/app/static/js/vault.js @@ -255,6 +255,7 @@ const Vault = (() => { const showCopyUser = item.item_type === 'password'; const showCopyPass = ['password','card','bank','ssn'].includes(item.item_type); + const showLaunch = item.item_type === 'password' && item.plain?.url; li.innerHTML = `
${icon}
@@ -263,12 +264,21 @@ const Vault = (() => { ${escHtml(subText)}
+ ${showLaunch ? `` : ''} ${showCopyUser ? `` : ''} ${showCopyPass ? `` : ''}
`; + 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) { li.querySelector('[data-action="copy-user"]').addEventListener('click', e => { 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 = ` + `; + 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 ────────────────────────────────────────────────────── let _pendingMfaSecret = null; @@ -1123,6 +1318,10 @@ const Vault = (() => { } document.getElementById('item-modal').classList.add('open'); 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 || ''; } @@ -1303,7 +1502,10 @@ const Vault = (() => { document.getElementById('btn-logout')?.addEventListener('click', handleLogout); 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-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-sharing')?.addEventListener('click', () => switchView('sharing')); document.getElementById('sidebar-emergency')?.addEventListener('click', () => switchView('emergency')); + document.getElementById('sidebar-generator')?.addEventListener('click', () => openPasswordGeneratorModal()); document.querySelectorAll('[data-type-filter]').forEach(el => { el.addEventListener('click', () => { @@ -1332,7 +1535,7 @@ const Vault = (() => { }); 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) const closeDetailModal = () => closeModal('detail-modal'); diff --git a/app/templates/vault/index.html b/app/templates/vault/index.html index cc9d3e4..5d29e70 100644 --- a/app/templates/vault/index.html +++ b/app/templates/vault/index.html @@ -46,6 +46,9 @@ +