04/18 Enhance app functionalities: master password reset, account deletion/recovery
This commit is contained in:
+276
-1
@@ -1062,7 +1062,19 @@ const Vault = (() => {
|
||||
|
||||
async function openSettingsModal() {
|
||||
document.getElementById('settings-modal').classList.add('open');
|
||||
await Promise.all([loadMfaStatus(), loadSharingKeysStatus()]);
|
||||
// Reset change password fields
|
||||
['cp-current', 'cp-new', 'cp-confirm'].forEach(id => {
|
||||
const el = document.getElementById(id); if (el) el.value = '';
|
||||
});
|
||||
document.getElementById('cp-error')?.classList.add('hidden');
|
||||
// Reset delete account area
|
||||
document.getElementById('delete-confirm-area')?.classList.add('hidden');
|
||||
document.getElementById('btn-delete-account')?.classList.remove('hidden');
|
||||
document.getElementById('delete-password').value = '';
|
||||
document.getElementById('delete-error')?.classList.add('hidden');
|
||||
// Reset recovery display
|
||||
document.getElementById('recovery-code-display')?.classList.add('hidden');
|
||||
await Promise.all([loadMfaStatus(), loadSharingKeysStatus(), loadRecoveryStatus()]);
|
||||
}
|
||||
|
||||
async function loadMfaStatus() {
|
||||
@@ -1196,6 +1208,248 @@ const Vault = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Change Password ───────────────────────────────────────────────────────
|
||||
|
||||
async function handleChangePassword() {
|
||||
const cpError = document.getElementById('cp-error');
|
||||
cpError.classList.add('hidden');
|
||||
|
||||
const currentPass = document.getElementById('cp-current').value;
|
||||
const newPass = document.getElementById('cp-new').value;
|
||||
const confirmPass = document.getElementById('cp-confirm').value;
|
||||
|
||||
if (!currentPass || !newPass || !confirmPass) {
|
||||
cpError.textContent = 'All fields are required.';
|
||||
cpError.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
if (newPass !== confirmPass) {
|
||||
cpError.textContent = 'New passwords do not match.';
|
||||
cpError.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
if (newPass.length < 12) {
|
||||
cpError.textContent = 'New password must be at least 12 characters.';
|
||||
cpError.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
if (currentPass === newPass) {
|
||||
cpError.textContent = 'New password must be different from the current password.';
|
||||
cpError.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btn-change-password');
|
||||
const originalText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Re-encrypting vault…';
|
||||
|
||||
try {
|
||||
const vaultKey = VaultSession.getKey();
|
||||
if (!vaultKey) { showToast('Vault is locked. Reload and unlock first.', 'error'); return; }
|
||||
|
||||
// Fetch current user email from session storage
|
||||
const encKeySalt = sessionStorage.getItem('enc_key_salt');
|
||||
|
||||
// We need the email to derive auth hashes — fetch it from the profile
|
||||
const profileRes = await apiFetch('/api/auth/me');
|
||||
if (!profileRes) return;
|
||||
const profile = await profileRes.json();
|
||||
const email = profile.email;
|
||||
|
||||
// Derive both auth hashes
|
||||
const currentAuthHash = await Crypto.deriveAuthHash(currentPass, email);
|
||||
const newAuthHash = await Crypto.deriveAuthHash(newPass, email);
|
||||
const newEncKeySalt = Crypto.generateSalt(16);
|
||||
const newVaultKey = await Crypto.deriveVaultKey(newPass, newEncKeySalt);
|
||||
|
||||
// Fetch all vault items and re-encrypt
|
||||
const itemsRes = await apiFetch('/api/vault');
|
||||
if (!itemsRes) return;
|
||||
const items = await itemsRes.json();
|
||||
|
||||
btn.textContent = `Re-encrypting ${items.length} item(s)…`;
|
||||
|
||||
const reEncrypted = [];
|
||||
for (const item of items) {
|
||||
const plain = await Crypto.decryptItem(vaultKey, item.enc_data, item.iv);
|
||||
const { enc_data, iv } = await Crypto.encryptItem(newVaultKey, plain);
|
||||
reEncrypted.push({ id: item.id, enc_data, iv });
|
||||
}
|
||||
|
||||
// Submit atomic password change
|
||||
const res = await apiFetch('/api/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
current_auth_hash: currentAuthHash,
|
||||
new_auth_hash: newAuthHash,
|
||||
new_enc_key_salt: newEncKeySalt,
|
||||
items: reEncrypted,
|
||||
}),
|
||||
});
|
||||
if (!res) return;
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
cpError.textContent = data.error || 'Password change failed.';
|
||||
cpError.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
showToast('Password changed. Please log in again.');
|
||||
setTimeout(() => redirectToLogin(), 1500);
|
||||
} catch (err) {
|
||||
cpError.textContent = 'An error occurred: ' + err.message;
|
||||
cpError.classList.remove('hidden');
|
||||
console.error(err);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Account Recovery Setup ────────────────────────────────────────────────
|
||||
|
||||
async function loadRecoveryStatus() {
|
||||
try {
|
||||
const res = await apiFetch('/api/auth/recovery/status');
|
||||
if (!res) return;
|
||||
const data = await res.json();
|
||||
const statusEl = document.getElementById('recovery-status-text');
|
||||
const actionsEl = document.getElementById('recovery-actions');
|
||||
|
||||
if (data.recovery_configured) {
|
||||
statusEl.textContent = '✅ A recovery code is configured for your account.';
|
||||
actionsEl.innerHTML = '<button class="btn-secondary" id="btn-regen-recovery">Generate new recovery code</button>';
|
||||
} else {
|
||||
statusEl.textContent = 'No recovery code set up. If you forget your master password, your vault cannot be recovered.';
|
||||
actionsEl.innerHTML = '<button class="btn-primary" id="btn-gen-recovery">Generate recovery code</button>';
|
||||
}
|
||||
document.getElementById('btn-gen-recovery')?.addEventListener('click', handleSetupRecovery);
|
||||
document.getElementById('btn-regen-recovery')?.addEventListener('click', handleSetupRecovery);
|
||||
} catch (err) {
|
||||
console.error('loadRecoveryStatus:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSetupRecovery() {
|
||||
const vaultKey = VaultSession.getKey();
|
||||
if (!vaultKey) { showToast('Vault is locked. Unlock first.', 'error'); return; }
|
||||
|
||||
try {
|
||||
const encKeySalt = sessionStorage.getItem('enc_key_salt');
|
||||
if (!encKeySalt) { showToast('Session error. Please reload.', 'error'); return; }
|
||||
|
||||
// Generate a random 128-bit (16-byte) recovery code displayed as hex
|
||||
const rawBytes = window.crypto.getRandomValues(new Uint8Array(16));
|
||||
const recoveryCode = Array.from(rawBytes).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
// Derive recovery key from the code
|
||||
const recoveryKeyMaterial = await window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(recoveryCode),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey']
|
||||
);
|
||||
const recoveryKey = await window.crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: new TextEncoder().encode('passkeeper-recovery'),
|
||||
iterations: 200_000,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
recoveryKeyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
|
||||
// Encrypt enc_key_salt with recovery key
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await window.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
recoveryKey,
|
||||
new TextEncoder().encode(encKeySalt)
|
||||
);
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
let bin = '';
|
||||
new Uint8Array(bytes).forEach(b => (bin += String.fromCharCode(b)));
|
||||
return btoa(bin);
|
||||
}
|
||||
|
||||
const recovery_enc_salt = bytesToBase64(ciphertext);
|
||||
const recovery_iv = bytesToBase64(iv);
|
||||
|
||||
// Store on server
|
||||
const res = await apiFetch('/api/auth/recovery/setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ recovery_enc_salt, recovery_iv }),
|
||||
});
|
||||
if (!res) return;
|
||||
|
||||
// Display the code to the user — formatted in groups of 4
|
||||
const formatted = recoveryCode.match(/.{1,4}/g).join('-');
|
||||
document.getElementById('recovery-code-value').textContent = formatted;
|
||||
document.getElementById('recovery-code-display').classList.remove('hidden');
|
||||
document.getElementById('recovery-actions').innerHTML = '';
|
||||
document.getElementById('recovery-status-text').textContent = 'Your new recovery code is shown below.';
|
||||
|
||||
} catch (err) {
|
||||
showToast('Recovery code generation failed: ' + err.message, 'error');
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Delete Account ────────────────────────────────────────────────────────
|
||||
|
||||
async function handleDeleteAccount() {
|
||||
const deleteError = document.getElementById('delete-error');
|
||||
deleteError.classList.add('hidden');
|
||||
const password = document.getElementById('delete-password').value;
|
||||
if (!password) {
|
||||
deleteError.textContent = 'Please enter your master password.';
|
||||
deleteError.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('btn-delete-confirm');
|
||||
const originalText = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Deleting…';
|
||||
|
||||
try {
|
||||
const profileRes = await apiFetch('/api/auth/me');
|
||||
if (!profileRes) return;
|
||||
const profile = await profileRes.json();
|
||||
const authHash = await Crypto.deriveAuthHash(password, profile.email);
|
||||
|
||||
const res = await apiFetch('/api/auth/account', {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ auth_hash: authHash }),
|
||||
});
|
||||
if (!res) return;
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
deleteError.textContent = data.error || 'Deletion failed.';
|
||||
deleteError.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear all local state and redirect
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
window.location.href = '/login';
|
||||
} catch (err) {
|
||||
deleteError.textContent = 'An error occurred: ' + err.message;
|
||||
deleteError.classList.remove('hidden');
|
||||
console.error(err);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Folder CRUD ───────────────────────────────────────────────────────────
|
||||
|
||||
function showNewFolderRow() {
|
||||
@@ -1570,6 +1824,27 @@ const Vault = (() => {
|
||||
document.getElementById('btn-mfa-cancel-setup')?.addEventListener('click', () => { document.getElementById('mfa-setup-area').classList.add('hidden'); loadMfaStatus(); });
|
||||
document.getElementById('btn-mfa-disable-confirm')?.addEventListener('click', handleMfaDisableConfirm);
|
||||
document.getElementById('btn-mfa-disable-cancel')?.addEventListener('click', () => { document.getElementById('mfa-disable-area').classList.add('hidden'); loadMfaStatus(); });
|
||||
document.getElementById('btn-change-password')?.addEventListener('click', handleChangePassword);
|
||||
document.getElementById('btn-delete-account')?.addEventListener('click', () => {
|
||||
document.getElementById('btn-delete-account').classList.add('hidden');
|
||||
document.getElementById('delete-confirm-area').classList.remove('hidden');
|
||||
document.getElementById('delete-password').focus();
|
||||
});
|
||||
document.getElementById('btn-delete-cancel')?.addEventListener('click', () => {
|
||||
document.getElementById('delete-confirm-area').classList.add('hidden');
|
||||
document.getElementById('btn-delete-account').classList.remove('hidden');
|
||||
document.getElementById('delete-password').value = '';
|
||||
document.getElementById('delete-error').classList.add('hidden');
|
||||
});
|
||||
document.getElementById('btn-delete-confirm')?.addEventListener('click', handleDeleteAccount);
|
||||
document.getElementById('btn-copy-recovery-code')?.addEventListener('click', () => {
|
||||
const code = document.getElementById('recovery-code-value').textContent;
|
||||
navigator.clipboard.writeText(code).then(() => showToast('Recovery code copied'));
|
||||
});
|
||||
document.getElementById('btn-recovery-done')?.addEventListener('click', () => {
|
||||
document.getElementById('recovery-code-display').classList.add('hidden');
|
||||
loadRecoveryStatus();
|
||||
});
|
||||
|
||||
// Share modal
|
||||
document.getElementById('btn-share-item')?.addEventListener('click', openShareModal);
|
||||
|
||||
Reference in New Issue
Block a user