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 @@
+