🔐 RESTORE: Admin password reset functionality

 Added admin password reset capability with:
   - Generate secure temporary passwords (12 chars with symbols)
   - Create password reset links with 15-min expiry
   - Beautiful modal UI with copy-to-clipboard functionality
   - Toggle password visibility for security
   - Reset buttons in both users list and user detail pages
   - Proper error handling and loading states
   - Responsive design for mobile/desktop

FEATURES:
- Admins can reset any user's password instantly
- Users get both temporary password and reset link options
- Secure password generation with proper entropy
- Audit logging of all password reset actions
- Professional UI with Bootstrap modals and tooltips

SAFE: Only affects admin functionality, calculator unchanged
This commit is contained in:
Gan, Jimmy
2025-07-11 13:36:05 +08:00
parent fefe9b10c0
commit ea5b4e5f18
3 changed files with 414 additions and 3 deletions
+46
View File
@@ -478,6 +478,52 @@ def reset_cost_factors():
flash('An error occurred while resetting cost factors.', 'error') flash('An error occurred while resetting cost factors.', 'error')
return redirect(url_for('admin.cost_factors')) return redirect(url_for('admin.cost_factors'))
@bp.route('/user/<int:user_id>/reset-password', methods=['POST'])
@login_required
@limiter.limit("10 per minute")
def admin_reset_password(user_id):
"""Admin function to reset a user's password"""
try:
user = User.query.get_or_404(user_id)
# Generate a temporary password
import secrets
import string
# Generate a secure temporary password (12 characters with mix of letters, numbers, symbols)
temp_password = ''.join(secrets.choice(string.ascii_letters + string.digits + '!@#$%^&*') for _ in range(12))
# Set the new password
user.set_password(temp_password)
# Generate a reset token for the user (optional - they can use this to set their own password)
reset_token = user.generate_reset_token()
db.session.commit()
# Log the password reset
current_app.logger.info(f'Admin {current_user.email} reset password for user {user.email}')
# Create reset URL
reset_url = url_for('auth.reset_password', token=reset_token, _external=True)
return jsonify({
'status': 'success',
'message': 'Password reset successfully',
'temporary_password': temp_password,
'reset_url': reset_url,
'username': user.username,
'email': user.email
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f'Admin password reset error: {str(e)}')
return jsonify({
'status': 'error',
'message': 'An error occurred while resetting the password'
}), 500
@bp.route('/debug-endpoints') @bp.route('/debug-endpoints')
def debug_endpoints(): def debug_endpoints():
from flask import current_app from flask import current_app
+165
View File
@@ -81,6 +81,14 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button type="button"
class="btn btn-outline-secondary reset-password-btn"
data-user-id="{{ user.id }}"
data-username="{{ user.username }}"
data-email="{{ user.email }}"
title="Reset User Password">
<i class="fas fa-key me-2"></i>Reset Password
</button>
<button type="button" <button type="button"
class="btn btn-{{ 'warning' if user.is_active else 'success' }} toggle-user-btn" class="btn btn-{{ 'warning' if user.is_active else 'success' }} toggle-user-btn"
data-user-id="{{ user.id }}" data-user-id="{{ user.id }}"
@@ -241,7 +249,164 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
}); });
}); });
// Reset password functionality
document.querySelectorAll('.reset-password-btn').forEach(button => {
button.addEventListener('click', function() {
const userId = this.dataset.userId;
const username = this.dataset.username;
const email = this.dataset.email;
if (!confirm(`Are you sure you want to reset the password for user "${username}"?\n\nThis will generate a new temporary password and invalidate their current password.`)) {
return;
}
// Show loading state
const originalHtml = this.innerHTML;
this.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Resetting Password...';
this.disabled = true;
fetch(`/admin/user/${userId}/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showPasswordResetModal(data);
} else {
alert('Error: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while resetting the password');
})
.finally(() => {
// Restore button state
this.innerHTML = originalHtml;
this.disabled = false;
}); });
});
});
});
function showPasswordResetModal(data) {
const modalHtml = `
<div class="modal fade" id="passwordResetModal" tabindex="-1" aria-labelledby="passwordResetModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="passwordResetModalLabel">
<i class="fas fa-check-circle me-2"></i>Password Reset Successful
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-success">
<i class="fas fa-info-circle me-2"></i>
Password has been reset for user <strong>${data.username}</strong> (${data.email})
</div>
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="fas fa-key me-2"></i>Temporary Password</h6>
</div>
<div class="card-body">
<div class="input-group">
<input type="password" class="form-control" id="tempPassword" value="${data.temporary_password}" readonly>
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility()">
<i class="fas fa-eye" id="passwordToggleIcon"></i>
</button>
<button class="btn btn-outline-primary" type="button" onclick="copyToClipboard('tempPassword')">
<i class="fas fa-copy"></i> Copy
</button>
</div>
<small class="text-muted">The user can log in with this temporary password and should change it immediately.</small>
</div>
</div>
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="fas fa-link me-2"></i>Password Reset Link</h6>
</div>
<div class="card-body">
<div class="input-group">
<input type="text" class="form-control" id="resetUrl" value="${data.reset_url}" readonly>
<button class="btn btn-outline-primary" type="button" onclick="copyToClipboard('resetUrl')">
<i class="fas fa-copy"></i> Copy
</button>
</div>
<small class="text-muted">Send this link to the user so they can set their own password. Link expires in 15 minutes.</small>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if present
const existingModal = document.getElementById('passwordResetModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to body
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('passwordResetModal'));
modal.show();
// Clean up modal after it's hidden
document.getElementById('passwordResetModal').addEventListener('hidden.bs.modal', function () {
this.remove();
});
}
function togglePasswordVisibility() {
const passwordField = document.getElementById('tempPassword');
const toggleIcon = document.getElementById('passwordToggleIcon');
if (passwordField.type === 'password') {
passwordField.type = 'text';
toggleIcon.className = 'fas fa-eye-slash';
} else {
passwordField.type = 'password';
toggleIcon.className = 'fas fa-eye';
}
}
function copyToClipboard(elementId) {
const element = document.getElementById(elementId);
element.select();
element.setSelectionRange(0, 99999); // For mobile devices
navigator.clipboard.writeText(element.value).then(function() {
// Show temporary success feedback
const button = event.target.closest('button');
const originalHtml = button.innerHTML;
button.innerHTML = '<i class="fas fa-check"></i> Copied!';
button.classList.add('btn-success');
button.classList.remove('btn-outline-primary');
setTimeout(() => {
button.innerHTML = originalHtml;
button.classList.remove('btn-success');
button.classList.add('btn-outline-primary');
}, 2000);
}).catch(function(err) {
console.error('Could not copy text: ', err);
alert('Could not copy to clipboard');
});
}
</script> </script>
{% endblock %} {% endblock %}
+203 -3
View File
@@ -88,15 +88,25 @@
title="View User Details" title="View User Details"
data-bs-toggle="tooltip"> data-bs-toggle="tooltip">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
<span class="d-none d-md-inline ms-1">View</span> <span class="d-none d-lg-inline ms-1">View</span>
</a> </a>
<button type="button"
class="btn btn-outline-secondary btn-sm reset-password-btn"
data-user-id="{{ user.id }}"
data-username="{{ user.username }}"
data-email="{{ user.email }}"
title="Reset User Password"
data-bs-toggle="tooltip">
<i class="fas fa-key"></i>
<span class="d-none d-lg-inline ms-1">Reset</span>
</button>
<button type="button" <button type="button"
class="btn btn-{{ 'warning' if user.is_active else 'success' }} btn-sm" class="btn btn-{{ 'warning' if user.is_active else 'success' }} btn-sm"
onclick="toggleUserStatus({{ user.id }}, '{{ 'warning' if user.is_active else 'success' }}')" onclick="toggleUserStatus({{ user.id }}, '{{ 'warning' if user.is_active else 'success' }}')"
title="{{ 'Deactivate User' if user.is_active else 'Activate User' }}" title="{{ 'Deactivate User' if user.is_active else 'Activate User' }}"
data-bs-toggle="tooltip"> data-bs-toggle="tooltip">
<i class="fas fa-{{ 'ban' if user.is_active else 'check' }}"></i> <i class="fas fa-{{ 'ban' if user.is_active else 'check' }}"></i>
<span class="d-none d-md-inline ms-1">{{ 'Deactivate' if user.is_active else 'Activate' }}</span> <span class="d-none d-lg-inline ms-1">{{ 'Deactivate' if user.is_active else 'Activate' }}</span>
</button> </button>
{% if user.id != current_user.id %} {% if user.id != current_user.id %}
<button type="button" <button type="button"
@@ -105,7 +115,7 @@
title="Delete User Permanently" title="Delete User Permanently"
data-bs-toggle="tooltip"> data-bs-toggle="tooltip">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
<span class="d-none d-md-inline ms-1">Delete</span> <span class="d-none d-lg-inline ms-1">Delete</span>
</button> </button>
{% endif %} {% endif %}
</div> </div>
@@ -176,7 +186,164 @@ document.addEventListener('DOMContentLoaded', function() {
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl); return new bootstrap.Tooltip(tooltipTriggerEl);
}); });
// Reset password functionality
document.querySelectorAll('.reset-password-btn').forEach(button => {
button.addEventListener('click', function() {
const userId = this.dataset.userId;
const username = this.dataset.username;
const email = this.dataset.email;
if (!confirm(`Are you sure you want to reset the password for user "${username}"?\n\nThis will generate a new temporary password and invalidate their current password.`)) {
return;
}
// Show loading state
const originalHtml = this.innerHTML;
this.innerHTML = '<i class="fas fa-spinner fa-spin"></i><span class="d-none d-lg-inline ms-1">Resetting...</span>';
this.disabled = true;
fetch(`/admin/user/${userId}/reset-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
}
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
showPasswordResetModal(data);
} else {
alert('Error: ' + data.message);
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while resetting the password');
})
.finally(() => {
// Restore button state
this.innerHTML = originalHtml;
this.disabled = false;
}); });
});
});
});
function showPasswordResetModal(data) {
const modalHtml = `
<div class="modal fade" id="passwordResetModal" tabindex="-1" aria-labelledby="passwordResetModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="passwordResetModalLabel">
<i class="fas fa-check-circle me-2"></i>Password Reset Successful
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-success">
<i class="fas fa-info-circle me-2"></i>
Password has been reset for user <strong>${data.username}</strong> (${data.email})
</div>
<div class="card mb-3">
<div class="card-header">
<h6 class="mb-0"><i class="fas fa-key me-2"></i>Temporary Password</h6>
</div>
<div class="card-body">
<div class="input-group">
<input type="password" class="form-control" id="tempPassword" value="${data.temporary_password}" readonly>
<button class="btn btn-outline-secondary" type="button" onclick="togglePasswordVisibility()">
<i class="fas fa-eye" id="passwordToggleIcon"></i>
</button>
<button class="btn btn-outline-primary" type="button" onclick="copyToClipboard('tempPassword')">
<i class="fas fa-copy"></i> Copy
</button>
</div>
<small class="text-muted">The user can log in with this temporary password and should change it immediately.</small>
</div>
</div>
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="fas fa-link me-2"></i>Password Reset Link</h6>
</div>
<div class="card-body">
<div class="input-group">
<input type="text" class="form-control" id="resetUrl" value="${data.reset_url}" readonly>
<button class="btn btn-outline-primary" type="button" onclick="copyToClipboard('resetUrl')">
<i class="fas fa-copy"></i> Copy
</button>
</div>
<small class="text-muted">Send this link to the user so they can set their own password. Link expires in 15 minutes.</small>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if present
const existingModal = document.getElementById('passwordResetModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to body
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('passwordResetModal'));
modal.show();
// Clean up modal after it's hidden
document.getElementById('passwordResetModal').addEventListener('hidden.bs.modal', function () {
this.remove();
});
}
function togglePasswordVisibility() {
const passwordField = document.getElementById('tempPassword');
const toggleIcon = document.getElementById('passwordToggleIcon');
if (passwordField.type === 'password') {
passwordField.type = 'text';
toggleIcon.className = 'fas fa-eye-slash';
} else {
passwordField.type = 'password';
toggleIcon.className = 'fas fa-eye';
}
}
function copyToClipboard(elementId) {
const element = document.getElementById(elementId);
element.select();
element.setSelectionRange(0, 99999); // For mobile devices
navigator.clipboard.writeText(element.value).then(function() {
// Show temporary success feedback
const button = event.target.closest('button');
const originalHtml = button.innerHTML;
button.innerHTML = '<i class="fas fa-check"></i> Copied!';
button.classList.add('btn-success');
button.classList.remove('btn-outline-primary');
setTimeout(() => {
button.innerHTML = originalHtml;
button.classList.remove('btn-success');
button.classList.add('btn-outline-primary');
}, 2000);
}).catch(function(err) {
console.error('Could not copy text: ', err);
alert('Could not copy to clipboard');
});
}
function toggleUserStatus(userId, status) { function toggleUserStatus(userId, status) {
const button = event.target.closest('button'); const button = event.target.closest('button');
@@ -263,5 +430,38 @@ function deleteUser(userId) {
.btn-group .btn:last-child { .btn-group .btn:last-child {
margin-right: 0; margin-right: 0;
} }
/* Action buttons responsiveness */
@media (max-width: 1199.98px) {
.btn-group .btn span {
display: none !important;
}
.btn-group .btn {
padding: 0.25rem 0.5rem;
min-width: 32px;
}
}
/* Reset password button styling */
.reset-password-btn:hover {
background-color: #6c757d;
border-color: #6c757d;
color: white;
}
/* Modal improvements */
.modal-lg {
max-width: 800px;
}
.input-group .form-control {
font-family: 'Courier New', monospace;
font-size: 0.9rem;
}
.alert-success {
border-left: 4px solid #28a745;
}
</style> </style>
{% endblock %} {% endblock %}