🔐 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:
@@ -478,6 +478,52 @@ def reset_cost_factors():
|
||||
flash('An error occurred while resetting cost factors.', 'error')
|
||||
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')
|
||||
def debug_endpoints():
|
||||
from flask import current_app
|
||||
|
||||
@@ -81,6 +81,14 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<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"
|
||||
class="btn btn-{{ 'warning' if user.is_active else 'success' }} toggle-user-btn"
|
||||
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>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -88,15 +88,25 @@
|
||||
title="View User Details"
|
||||
data-bs-toggle="tooltip">
|
||||
<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>
|
||||
<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"
|
||||
class="btn btn-{{ 'warning' if user.is_active else 'success' }} btn-sm"
|
||||
onclick="toggleUserStatus({{ user.id }}, '{{ 'warning' if user.is_active else 'success' }}')"
|
||||
title="{{ 'Deactivate User' if user.is_active else 'Activate User' }}"
|
||||
data-bs-toggle="tooltip">
|
||||
<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>
|
||||
{% if user.id != current_user.id %}
|
||||
<button type="button"
|
||||
@@ -105,7 +115,7 @@
|
||||
title="Delete User Permanently"
|
||||
data-bs-toggle="tooltip">
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -176,8 +186,165 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
var tooltipList = tooltipTriggerList.map(function (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) {
|
||||
const button = event.target.closest('button');
|
||||
const isActive = status === 'warning';
|
||||
@@ -263,5 +430,38 @@ function deleteUser(userId) {
|
||||
.btn-group .btn:last-child {
|
||||
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>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user