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">&nbsp;</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 %}