users_list.html
543 lignes · 27251 octets
{% extends "base.html" %} {% block title %}{{ page_title }}{% endblock %} {% block navbar %} {% include "components/navbar.html" %} {% endblock %} {% block content %} <div class="container mx-auto px-4 py-8"> <!-- Breadcrumb --> <div class="text-sm breadcrumbs mb-6"> <ul> <li><a href="/">{{ breadcrumb_home }}</a></li> <li><a href="/admin">{{ breadcrumb_admin }}</a></li> <li>{{ breadcrumb_users }}</li> </ul> </div> <div class="flex justify-between items-center mb-6"> <h1 class="text-3xl font-bold">{{ title }}</h1> <a href="/admin/users/create" class="btn btn-primary"> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /> </svg> {{ create_user }} </a> </div> <!-- Search and Filter Form --> <div class="card bg-base-100 shadow-xl mb-6"> <div class="card-body"> <form method="GET" action="/admin/users" class="grid grid-cols-1 md:grid-cols-4 gap-4"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <!-- Search Input --> <div class="form-control"> <label class="label"> <span class="label-text">{{ search_label }}</span> </label> <input type="text" name="search" placeholder="{{ search_placeholder }}" class="input input-bordered" value="{{ current_search }}" > </div> <!-- Role Filter --> <div class="form-control"> <label class="label"> <span class="label-text">{{ role_filter_label }}</span> </label> <select name="role_filter" class="select select-bordered"> <option value="">{{ all_roles }}</option> <option value="admin" {% if is_admin_filter %}selected{% endif %}>Admin</option> <option value="user" {% if is_user_filter %}selected{% endif %}>User</option> </select> </div> <!-- Per Page --> <div class="form-control"> <label class="label"> <span class="label-text">{{ per_page_label }}</span> </label> <select name="per_page" class="select select-bordered"> <option value="25" {% if per_page == 25 %}selected{% endif %}>25</option> <option value="50" {% if per_page == 50 %}selected{% endif %}>50</option> <option value="100" {% if per_page == 100 %}selected{% endif %}>100</option> </select> </div> <!-- Submit Button --> <div class="form-control"> <label class="label"> <span class="label-text"> </span> </label> <button type="submit" class="btn btn-primary">{{ search_button }}</button> </div> </form> </div> </div> <!-- Results Summary --> <div class="mb-4"> <p class="text-sm text-base-content/70"> {{ results_summary }} {% if total_pages > 1 %} - {{ page_info }} {% endif %} </p> </div> <!-- Users Table --> <div class="card bg-base-100 shadow-xl"> <div class="card-body"> {% if users.is_empty() %} <div class="text-center py-8"> <p class="text-lg text-base-content/70">{{ no_users }}</p> </div> {% else %} <div class="overflow-x-auto"> <table class="table table-zebra w-full"> <thead> <tr> <th>{{ table_username }}</th> <th>{{ table_email }}</th> <th>{{ table_roles }}</th> <th>2FA</th> <th>{{ table_created_at }}</th> <th>{{ table_last_login }}</th> <th>{{ table_actions }}</th> </tr> </thead> <tbody> {% for user in users %} <tr> <td> <div class="font-bold">{{ user.username }}</div> </td> <td>{{ user.email }}</td> <td> <div class="flex flex-wrap gap-1"> {% for role in user.roles %} <span class="badge {% if role.is_admin %}badge-error{% else %}badge-info{% endif %}"> {{ role.name }} </span> {% endfor %} </div> </td> <td> {% if user.totp_enabled %} <span class="badge badge-success gap-1"> <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg> </span> {% else %} <span class="badge badge-ghost text-xs">-</span> {% endif %} </td> <td> <div class="text-sm">{{ user.created_at }}</div> </td> <td> {% if user.has_last_login %} <div class="text-sm">{{ user.last_login_at }}</div> {% if user.has_last_login_ip %} <div class="text-xs text-base-content/50">{{ user.last_login_ip }}</div> {% endif %} {% else %} <span class="text-sm text-base-content/50">{{ never }}</span> {% endif %} </td> <td> <div class="flex gap-2"> <a href="/admin/users/{{ user.id }}" class="btn btn-sm btn-ghost"> {{ edit }} </a> <button type="button" class="btn btn-sm btn-warning btn-reset-password" data-user-id="{{ user.id }}" data-username="{{ user.username }}" title="{{ reset_password_title }}" > <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /> </svg> {{ reset_password }} </button> <button type="button" class="btn btn-sm btn-error btn-delete-user" data-user-id="{{ user.id }}" data-username="{{ user.username }}" data-email="{{ user.email }}" > <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> </svg> {{ delete }} </button> </div> </td> </tr> {% endfor %} </tbody> </table> </div> {% endif %} </div> </div> <!-- Pagination --> {% if total_pages > 1 %} <div class="flex justify-center mt-6"> <div class="join"> {% if page > 1 %} <a href="?page={{ page - 1 }}&per_page={{ per_page }}{% if has_search %}&search={{ current_search }}{% endif %}{% if has_role_filter %}&role_filter={{ current_role_filter }}{% endif %}" class="join-item btn">«</a> {% else %} <button class="join-item btn btn-disabled">«</button> {% endif %} {% for p in 1..=total_pages %} {% if p == page %} <button class="join-item btn btn-active">{{ p }}</button> {% elif p == 1 || p == total_pages %} <a href="?page={{ p }}&per_page={{ per_page }}{% if has_search %}&search={{ current_search }}{% endif %}{% if has_role_filter %}&role_filter={{ current_role_filter }}{% endif %}" class="join-item btn">{{ p }}</a> {% elif p + 2 >= page && p <= page + 2 %} <a href="?page={{ p }}&per_page={{ per_page }}{% if has_search %}&search={{ current_search }}{% endif %}{% if has_role_filter %}&role_filter={{ current_role_filter }}{% endif %}" class="join-item btn">{{ p }}</a> {% elif p + 3 == page || p == page + 3 %} <button class="join-item btn btn-disabled">...</button> {% endif %} {% endfor %} {% if page < total_pages %} <a href="?page={{ page + 1 }}&per_page={{ per_page }}{% if has_search %}&search={{ current_search }}{% endif %}{% if has_role_filter %}&role_filter={{ current_role_filter }}{% endif %}" class="join-item btn">»</a> {% else %} <button class="join-item btn btn-disabled">»</button> {% endif %} </div> </div> {% endif %} </div> <!-- Modal Reset Password (T105) --> <dialog id="resetPasswordModal" class="modal"> <div class="modal-box"> <h3 class="font-bold text-lg mb-4">{{ reset_password_title }}</h3> <form id="resetPasswordForm" method="POST" class="space-y-4"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" id="resetUserId" name="user_id" value=""> <!-- Reset Option --> <div class="form-control"> <label class="label cursor-pointer"> <span class="label-text">{{ reset_generate }}</span> <input type="radio" name="reset_type" value="generate" class="radio radio-primary" checked> </label> </div> <div class="form-control"> <label class="label cursor-pointer"> <span class="label-text">{{ reset_custom }}</span> <input type="radio" name="reset_type" value="custom" class="radio radio-primary"> </label> </div> <div class="form-control"> <label class="label cursor-pointer"> <span class="label-text">{{ reset_email }}</span> <input type="radio" name="reset_type" value="email" class="radio radio-primary"> </label> </div> <!-- Custom Password Field (hidden by default) --> <div id="customPasswordField" class="form-control hidden"> <label class="label"> <span class="label-text">{{ reset_new_password_label }}</span> </label> <input type="password" name="new_password" id="newPassword" placeholder="{{ reset_new_password_placeholder }}" class="input input-bordered w-full" minlength="{{ pw_min_length }}" > <label class="label"> <span class="label-text-alt text-warning"> {{ reset_password_requirements }} </span> </label> </div> <!-- Invalidate Sessions Option --> <div class="form-control"> <label class="label cursor-pointer"> <span class="label-text">{{ reset_invalidate_sessions }}</span> <input type="checkbox" name="invalidate_sessions" value="true" class="checkbox checkbox-secondary" checked> </label> <label class="label"> <span class="label-text-alt">{{ reset_invalidate_sessions_desc }}</span> </label> </div> <div class="modal-action"> <button type="button" class="btn" onclick="document.getElementById('resetPasswordModal').close()">{{ cancel }}</button> <button type="submit" class="btn btn-error">{{ reset_button }}</button> </div> </form> </div> <form method="dialog" class="modal-backdrop"> <button>close</button> </form> </dialog> <!-- Modal Delete Confirmation (T093) --> <dialog id="deleteUserModal" class="modal"> <div class="modal-box"> <h3 class="font-bold text-lg text-error mb-4">{{ delete_confirm_title }}</h3> <p class="mb-4">{{ delete_confirm_message }}</p> <p class="text-sm text-base-content/70 mb-4"> <strong>{{ delete_confirm_username }}</strong> <span id="deleteUsername"></span><br> <strong>{{ delete_confirm_email }}</strong> <span id="deleteEmail"></span> </p> <div class="alert alert-warning mb-4"> <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg> <span>{{ delete_irreversible }}</span> </div> <form id="deleteUserForm" method="POST" class="modal-action"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" id="deleteUserId" name="user_id" value=""> <button type="button" class="btn" onclick="document.getElementById('deleteUserModal').close()">{{ cancel }}</button> <button type="submit" class="btn btn-error">{{ delete_button }}</button> </form> </div> <form method="dialog" class="modal-backdrop"> <button>close</button> </form> </dialog> <script> // Reset Password Modal Logic document.querySelectorAll('input[name="reset_type"]').forEach(radio => { radio.addEventListener('change', function() { const customField = document.getElementById('customPasswordField'); const newPasswordInput = document.getElementById('newPassword'); const form = document.getElementById('resetPasswordForm'); const userId = document.getElementById('resetUserId').value; if (this.value === 'custom') { customField.classList.remove('hidden'); newPasswordInput.setAttribute('required', 'required'); form.action = `/admin/users/${userId}/reset-password`; } else if (this.value === 'email') { customField.classList.add('hidden'); newPasswordInput.removeAttribute('required'); form.action = `/admin/users/${userId}/reset-password-email`; } else { customField.classList.add('hidden'); newPasswordInput.removeAttribute('required'); form.action = `/admin/users/${userId}/reset-password`; } }); }); // SEC-FIX: Use data-* attributes instead of inline onclick to prevent XSS document.querySelectorAll('.btn-reset-password').forEach(function(btn) { btn.addEventListener('click', function() { var userId = this.dataset.userId; document.getElementById('resetUserId').value = userId; document.getElementById('resetPasswordForm').action = '/admin/users/' + encodeURIComponent(userId) + '/reset-password'; document.querySelector('input[name="reset_type"][value="generate"]').checked = true; document.getElementById('customPasswordField').classList.add('hidden'); document.getElementById('newPassword').value = ''; document.getElementById('newPassword').removeAttribute('required'); document.getElementById('resetPasswordModal').showModal(); }); }); document.querySelectorAll('.btn-delete-user').forEach(function(btn) { btn.addEventListener('click', function() { var userId = this.dataset.userId; var username = this.dataset.username; var email = this.dataset.email; document.getElementById('deleteUserId').value = userId; document.getElementById('deleteUsername').textContent = username; document.getElementById('deleteEmail').textContent = email; document.getElementById('deleteUserForm').action = '/admin/users/' + encodeURIComponent(userId); document.getElementById('deleteUserModal').showModal(); }); }); // Handle Reset Password Form Submission document.getElementById('resetPasswordForm').addEventListener('submit', async function(e) { e.preventDefault(); const formData = new FormData(this); const resetType = formData.get('reset_type'); const newPassword = formData.get('new_password'); const userId = formData.get('user_id'); const submitBtn = this.querySelector('button[type="submit"]'); const originalBtnText = submitBtn.innerHTML; submitBtn.disabled = true; submitBtn.innerHTML = '<span class="loading loading-spinner"></span> {{ resetting }}'; try { let response; if (resetType === 'email') { // For email reset, send empty body (no JSON needed) response = await fetch(this.action, { method: 'POST', headers: { 'Accept': 'text/html', 'X-CSRF-Token': '{{ csrf_token }}', }, credentials: 'include', }); } else { // For generate/custom, send JSON with password const requestBody = { new_password: resetType === 'custom' && newPassword ? newPassword.toString() : null }; response = await fetch(this.action, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-Token': '{{ csrf_token }}', }, credentials: 'include', body: JSON.stringify(requestBody) }); } if (response.ok) { // Close the reset password dialog first so success UI is visible document.getElementById('resetPasswordModal').close(); submitBtn.disabled = false; submitBtn.innerHTML = originalBtnText; if (resetType === 'email') { var responseText = await response.text(); // SEC-FIX: Use textContent instead of innerHTML to prevent XSS var successModal = document.createElement('div'); successModal.className = 'modal modal-open'; successModal.innerHTML = '<div class="modal-box">' + '<h3 class="font-bold text-lg mb-4">{{ reset_by_email_title }}</h3>' + '<div class="mb-4 alert alert-success"></div>' + '<div class="modal-action">' + '<button type="button" class="btn btn-primary">{{ close }}</button>' + '</div></div>' + '<form method="dialog" class="modal-backdrop"><button>close</button></form>'; document.body.appendChild(successModal); // Set text safely via textContent successModal.querySelector('.alert').textContent = responseText.replace(/<[^>]*>/g, ''); successModal.querySelector('.btn-primary').addEventListener('click', function() { successModal.remove(); }); return; } var result = await response.json(); if (resetType === 'custom') { alert('{{ password_set_success }}'); window.location.reload(); } else if (result.password) { // SEC-FIX: Set password value via DOM property, not innerHTML var passwordDisplay = document.createElement('div'); passwordDisplay.className = 'modal modal-open'; passwordDisplay.innerHTML = '<div class="modal-box">' + '<h3 class="font-bold text-lg mb-4">{{ password_reset_success_title }}</h3>' + '<div class="alert alert-warning mb-4">' + '<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>' + '<span>{{ password_warning_once }}</span></div>' + '<div class="form-control mb-4"><label class="label"><span class="label-text font-semibold">{{ new_password_label }}</span></label>' + '<div class="join"><input type="text" id="displayPassword" class="input input-bordered join-item w-full font-mono text-lg" readonly>' + '<button type="button" class="btn btn-primary join-item" id="copyPasswordBtn"><svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> {{ copy }}</button>' + '</div></div>' + '<div class="modal-action"><button type="button" class="btn btn-primary" id="closePasswordModal">{{ close }}</button></div>' + '</div>'; document.body.appendChild(passwordDisplay); // Set password via DOM property (safe, no HTML parsing) document.getElementById('displayPassword').value = result.password; document.getElementById('closePasswordModal').addEventListener('click', function() { passwordDisplay.remove(); window.location.reload(); }); document.getElementById('copyPasswordBtn').addEventListener('click', function() { var passwordInput = document.getElementById('displayPassword'); navigator.clipboard.writeText(passwordInput.value).then(function() { alert('{{ password_copied }}'); }).catch(function() { passwordInput.select(); document.execCommand('copy'); alert('{{ password_copied }}'); }); }); } else { alert('{{ password_set_success }}'); window.location.reload(); } } else { const errorText = await response.text(); console.error('Error:', errorText); let errorMessage = '❌ {{ reset_error }}'; try { const errorJson = JSON.parse(errorText); if (errorJson.message) { errorMessage = '❌ ' + errorJson.message; } } catch (e) { // Not JSON, use default message } alert(errorMessage); submitBtn.disabled = false; submitBtn.innerHTML = originalBtnText; } } catch (error) { console.error('Error:', error); alert('{{ network_error }}'); submitBtn.disabled = false; submitBtn.innerHTML = originalBtnText; } }); // Handle form submissions document.getElementById('deleteUserForm').addEventListener('submit', async function(e) { e.preventDefault(); const formData = new FormData(this); const userId = formData.get('user_id'); try { const response = await fetch(this.action, { method: 'DELETE', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': '{{ csrf_token }}', }, body: new URLSearchParams(formData) }); if (response.ok) { window.location.reload(); } else { alert('{{ delete_error }}'); } } catch (error) { console.error('Error:', error); alert('{{ network_error }}'); } }); </script> {% endblock %}
GitRust