Skip to content

Commit f45a119

Browse files
Add account deletion feature with confirmation modal
- Add delete account endpoint with password/username verification - Support both local auth (password) and OIDC (username) confirmation - Delete user data directory and cascade delete from database - Add danger zone section to settings page with right-aligned button - Add delete confirmation modal with warning and input validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent b55e936 commit f45a119

File tree

3 files changed

+180
-1
lines changed

3 files changed

+180
-1
lines changed

app/assets/css/components/forms.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -760,6 +760,7 @@ html.dark-mode .radio-option:has(input[type="radio"]:checked) {
760760
margin-top: var(--spacing-xl);
761761
padding-top: var(--spacing-lg);
762762
border-top: 1px solid var(--border-color);
763+
overflow: hidden; /* clearfix for floated button */
763764
}
764765

765766
.danger-zone h4 {
@@ -771,6 +772,7 @@ html.dark-mode .radio-option:has(input[type="radio"]:checked) {
771772

772773
.danger-zone .btn-danger {
773774
margin-top: var(--spacing-md);
775+
float: right;
774776
}
775777

776778
.btn-danger {

app/routes/settings.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Settings routes for user preferences and configuration"""
2+
import os
3+
import shutil
24
from flask import Blueprint, render_template, request, jsonify, session
35
from werkzeug.security import generate_password_hash, check_password_hash
46
from database import get_db_connection
57
from utils.decorators import login_required
6-
from utils.config import VALID_CURRENCIES
8+
from utils.config import VALID_CURRENCIES, USER_DATA_BASE_FOLDER
79

810
settings_bp = Blueprint('settings', __name__)
911

@@ -389,3 +391,55 @@ def update_naming_patterns():
389391
'renamed_count': renamed_count,
390392
'errors': errors if errors else None
391393
})
394+
395+
396+
@settings_bp.route('/settings/delete-account', methods=['POST'])
397+
@login_required
398+
def delete_account():
399+
"""Permanently delete user account and all associated data"""
400+
data = request.get_json()
401+
user_id = session['user_id']
402+
auth_method = session.get('auth_method', 'local')
403+
404+
conn = get_db_connection()
405+
user = conn.execute('SELECT * FROM users WHERE id = ?', (user_id,)).fetchone()
406+
407+
if not user:
408+
conn.close()
409+
return jsonify({'success': False, 'message': 'User not found'}), 404
410+
411+
# Verify confirmation based on auth method
412+
if auth_method == 'oidc':
413+
# OIDC users confirm by typing their username
414+
confirmation = data.get('confirmation', '').strip()
415+
if confirmation != user['username']:
416+
conn.close()
417+
return jsonify({'success': False, 'message': 'Username does not match'}), 400
418+
else:
419+
# Local auth users confirm with password
420+
password = data.get('password')
421+
if not password or not check_password_hash(user['password_hash'], password):
422+
conn.close()
423+
return jsonify({'success': False, 'message': 'Incorrect password'}), 400
424+
425+
try:
426+
# 1. Delete user's file directory (receipts, benefit plans, etc.)
427+
user_data_dir = os.path.join(USER_DATA_BASE_FOLDER, str(user_id))
428+
if os.path.exists(user_data_dir):
429+
shutil.rmtree(user_data_dir)
430+
431+
# 2. Delete user from database (cascades to all related tables)
432+
conn.execute('DELETE FROM users WHERE id = ?', (user_id,))
433+
conn.commit()
434+
conn.close()
435+
436+
# 3. Clear session
437+
session.clear()
438+
439+
return jsonify({'success': True, 'message': 'Account deleted successfully'})
440+
441+
except Exception as e:
442+
print(f"Error deleting account: {e}")
443+
conn.rollback()
444+
conn.close()
445+
return jsonify({'success': False, 'message': f'Failed to delete account: {str(e)}'}), 500

app/templates/settings.html

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,19 @@ <h4>Change Password</h4>
403403
</p>
404404
</div>
405405
{% endif %}
406+
407+
<!-- Danger Zone Section -->
408+
<div class="danger-zone">
409+
<h4>Danger Zone</h4>
410+
<p class="help-text">Permanently delete your account and all associated data. This action cannot be undone.</p>
411+
<button type="button" class="btn btn-danger" onclick="openDeleteAccountModal()">
412+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" style="margin-right: 6px;">
413+
<path d="M3 6H5H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
414+
<path d="M8 6V4C8 3.46957 8.21071 2.96086 8.58579 2.58579C8.96086 2.21071 9.46957 2 10 2H14C14.5304 2 15.0391 2.21071 15.4142 2.58579C15.7893 2.96086 16 3.46957 16 4V6M19 6V20C19 20.5304 18.7893 21.0391 18.4142 21.4142C18.0391 21.7893 17.5304 22 17 22H7C6.46957 22 5.96086 21.7893 5.58579 21.4142C5.21071 21.0391 5 20.5304 5 20V6H19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
415+
</svg>
416+
Delete Account
417+
</button>
418+
</div>
406419
</div>
407420
</div>
408421
</div>
@@ -451,6 +464,48 @@ <h2>Edit Family Member</h2>
451464
</div>
452465
</div>
453466

467+
<!-- Delete Account Modal -->
468+
<div id="deleteAccountModal" class="modal">
469+
<div class="modal-content modal-small">
470+
<div class="modal-header modal-header-danger">
471+
<h2>Delete Account</h2>
472+
<span class="close" role="button" tabindex="0" aria-label="Close modal" onclick="closeDeleteAccountModal()">&times;</span>
473+
</div>
474+
<div class="modal-body">
475+
<div class="delete-warning">
476+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" class="warning-icon">
477+
<path d="M12 9V13M12 17H12.01M10.29 3.86L1.82 18C1.64 18.3 1.55 18.64 1.55 19C1.55 19.36 1.64 19.7 1.82 20C2 20.3 2.26 20.56 2.56 20.74C2.86 20.92 3.21 21.01 3.56 21H20.44C20.79 21.01 21.14 20.92 21.44 20.74C21.74 20.56 22 20.3 22.18 20C22.36 19.7 22.45 19.36 22.45 19C22.45 18.64 22.36 18.3 22.18 18L13.71 3.86C13.53 3.56 13.27 3.32 12.97 3.15C12.67 2.98 12.34 2.89 12 2.89C11.66 2.89 11.33 2.98 11.03 3.15C10.73 3.32 10.47 3.56 10.29 3.86Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
478+
</svg>
479+
<p><strong>This action cannot be undone.</strong></p>
480+
</div>
481+
<p>This will permanently delete:</p>
482+
<ul class="delete-list">
483+
<li>Your account and profile</li>
484+
<li>All expenses and transactions</li>
485+
<li>All receipts and attachments</li>
486+
<li>All plan year documents</li>
487+
<li>HSA balance history</li>
488+
</ul>
489+
490+
{% if not session.auth_method or session.auth_method == 'local' %}
491+
<div class="form-group">
492+
<label for="deleteAccountPassword">Enter your password to confirm:</label>
493+
<input type="password" id="deleteAccountPassword" placeholder="Your password" required>
494+
</div>
495+
{% else %}
496+
<div class="form-group">
497+
<label for="deleteAccountConfirmation">Type <strong>{{ session.username }}</strong> to confirm:</label>
498+
<input type="text" id="deleteAccountConfirmation" placeholder="{{ session.username }}" required autocomplete="off">
499+
</div>
500+
{% endif %}
501+
</div>
502+
<div class="modal-footer">
503+
<button type="button" class="btn btn-secondary" onclick="closeDeleteAccountModal()">Cancel</button>
504+
<button type="button" class="btn btn-danger" onclick="confirmDeleteAccount()">Delete My Account</button>
505+
</div>
506+
</div>
507+
</div>
508+
454509
<script>
455510
function handleFileSelect(event) {
456511
const file = event.target.files[0];
@@ -902,15 +957,83 @@ <h2>Edit Family Member</h2>
902957
window.addEventListener('click', function(event) {
903958
const providerModal = document.getElementById('editProviderModal');
904959
const familyMemberModal = document.getElementById('editFamilyMemberModal');
960+
const deleteAccountModal = document.getElementById('deleteAccountModal');
905961

906962
if (event.target === providerModal) {
907963
closeEditProviderModal();
908964
}
909965
if (event.target === familyMemberModal) {
910966
closeEditFamilyMemberModal();
911967
}
968+
if (event.target === deleteAccountModal) {
969+
closeDeleteAccountModal();
970+
}
912971
});
913972

973+
// Delete Account Modal Functions
974+
function openDeleteAccountModal() {
975+
openModal(document.getElementById('deleteAccountModal'));
976+
}
977+
978+
function closeDeleteAccountModal() {
979+
document.getElementById('deleteAccountModal').style.display = 'none';
980+
// Clear inputs
981+
const passwordInput = document.getElementById('deleteAccountPassword');
982+
const confirmInput = document.getElementById('deleteAccountConfirmation');
983+
if (passwordInput) passwordInput.value = '';
984+
if (confirmInput) confirmInput.value = '';
985+
}
986+
987+
function confirmDeleteAccount() {
988+
const passwordInput = document.getElementById('deleteAccountPassword');
989+
const confirmInput = document.getElementById('deleteAccountConfirmation');
990+
991+
let payload = {};
992+
if (passwordInput) {
993+
if (!passwordInput.value) {
994+
showAlert('Please enter your password', 'error');
995+
return;
996+
}
997+
payload.password = passwordInput.value;
998+
} else if (confirmInput) {
999+
if (!confirmInput.value) {
1000+
showAlert('Please type your username to confirm', 'error');
1001+
return;
1002+
}
1003+
payload.confirmation = confirmInput.value;
1004+
}
1005+
1006+
fetch('/settings/delete-account', addCSRFToken({
1007+
method: 'POST',
1008+
headers: {
1009+
'Content-Type': 'application/json',
1010+
},
1011+
body: JSON.stringify(payload)
1012+
}))
1013+
.then(async response => {
1014+
const text = await response.text();
1015+
try {
1016+
const data = JSON.parse(text);
1017+
return { ok: response.ok, data };
1018+
} catch (e) {
1019+
console.error('Invalid JSON response:', text);
1020+
throw new Error('Server error: ' + text.substring(0, 100));
1021+
}
1022+
})
1023+
.then(result => {
1024+
if (result.ok && result.data.success) {
1025+
// Redirect to login page
1026+
window.location.href = '/login';
1027+
} else {
1028+
showAlert(result.data.message || 'Failed to delete account', 'error');
1029+
}
1030+
})
1031+
.catch(error => {
1032+
console.error('Error:', error);
1033+
showAlert(error.message || 'An error occurred. Please try again.', 'error');
1034+
});
1035+
}
1036+
9141037
// Keyboard navigation: Tab to first family member after profile button/dropdown
9151038
document.addEventListener('DOMContentLoaded', function() {
9161039
const profileBtn = document.getElementById('profileBtn');

0 commit comments

Comments
 (0)