password.html 506 lignes · 22918 octets
{% extends "base.html" %}

{% block title %}{{ page_title }}{% endblock %}

{% block navbar %}
{% if !must_change %}
{% include "components/navbar.html" %}
{% endif %}
{% endblock %}

{% block content %}
{% if must_change %}
<div class="min-h-screen flex items-center justify-center bg-base-200 px-4 py-12">
    <div class="w-full max-w-lg">
        <div class="text-center mb-8">
            <div class="inline-flex items-center justify-center w-16 h-16 rounded-full bg-warning/20 mb-4">
                <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
            </div>
            <h1 class="text-3xl font-bold mb-2">{{ title }}</h1>
            <p class="text-base-content/70 text-lg">{{ must_change_message }}</p>
        </div>
{% else %}
<div class="container mx-auto px-4 py-8">
    <div class="text-sm breadcrumbs mb-6">
        <ul>
            <li><a href="/">{{ breadcrumb_home }}</a></li>
            <li><a href="/profile">{{ breadcrumb_settings }}</a></li>
            <li>{{ breadcrumb_password }}</li>
        </ul>
    </div>

    <div class="mb-8">
        <h1 class="text-3xl font-bold mb-2">{{ title }}</h1>
        {% if password_change_require_email %}
        <p class="text-base-content/70">{{ subtitle }}</p>
        {% else %}
        <p class="text-base-content/70">{{ subtitle_no_email }}</p>
        {% endif %}
    </div>
{% endif %}

    <!-- Feature 026: Pending password change request alert -->
    {% if has_pending_request %}
    <div class="alert alert-info mb-6 max-w-2xl">
        <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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
        <div class="flex-1">
            <h3 class="font-bold">{{ pending_request_message }}</h3>
            <div class="text-sm">{{ pending_request_info }}</div>
        </div>
        <div>
            <button 
                id="cancelPendingRequestBtn"
                class="btn btn-sm btn-ghost"
                onclick="cancelPendingRequest()"
            >
                {{ pending_request_cancel }}
            </button>
        </div>
    </div>
    {% endif %}

    <!-- Password Change Form Card -->
    <div class="card bg-base-100 shadow-xl max-w-2xl">
        <div class="card-body">
            <h2 class="card-title mb-4">
                <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
                {{ new_password_label }}
            </h2>
            
            <form id="passwordForm" method="POST" action="/settings/password" class="space-y-6">
                <!-- CSRF Token -->
                <input type="hidden" name="csrf_token" value="{{ csrf_token }}">

                <!-- Old Password -->
                <div class="form-control">
                    <label class="label">
                        <span class="label-text font-semibold">{{ current_password_label }}</span>
                        <span class="label-text-alt text-error">{{ required }}</span>
                    </label>
                    <input 
                        type="password" 
                        name="old_password" 
                        id="oldPassword"
                        placeholder="{{ current_password_placeholder }}" 
                        class="input input-bordered w-full"
                        required
                        autocomplete="current-password"
                    >
                </div>

                <!-- New Password -->
                <div class="form-control">
                    <label class="label">
                        <span class="label-text font-semibold">{{ new_password_label }}</span>
                        <span class="label-text-alt text-error">{{ required }}</span>
                    </label>
                    <input 
                        type="password" 
                        name="new_password" 
                        id="newPassword"
                        placeholder="{{ new_password_placeholder }}" 
                        class="input input-bordered w-full"
                        required
                        minlength="{{ pw_min_length }}"
                        autocomplete="new-password"
                    >
                    <label class="label">
                        <span class="label-text-alt text-base-content/70">
                            {{ requirements }}
                        </span>
                    </label>
                    <!-- Password Strength Indicator -->
                    <div class="mt-2">
                        <div id="strengthBar" class="hidden">
                            <label class="label">
                                <span class="label-text-alt">{{ strength_label }}</span>
                                <span id="strengthText" class="label-text-alt font-semibold"></span>
                            </label>
                            <progress id="strengthProgress" class="progress w-full" value="0" max="100"></progress>
                        </div>
                    </div>
                </div>

                <!-- Confirm Password -->
                <div class="form-control">
                    <label class="label">
                        <span class="label-text font-semibold">{{ confirm_password_label }}</span>
                        <span class="label-text-alt text-error">{{ required }}</span>
                    </label>
                    <input 
                        type="password" 
                        name="confirm_password" 
                        id="confirmPassword"
                        placeholder="{{ confirm_password_placeholder }}" 
                        class="input input-bordered w-full"
                        required
                        minlength="{{ pw_min_length }}"
                        autocomplete="new-password"
                    >
                    <label class="label">
                        <span id="matchIndicator" class="label-text-alt text-base-content/70"></span>
                    </label>
                </div>

                <!-- Security Alert -->
                <div class="alert alert-warning">
                    <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>
                    <div>
                        <h3 class="font-bold">{{ logout_warning_title }}</h3>
                        <div class="text-xs">{{ logout_warning_desc }}</div>
                    </div>
                </div>

                <!-- Form Actions -->
                <div class="card-actions justify-end">
                    {% if must_change %}
                    <a href="/api/v1/auth/logout" class="btn btn-ghost"
                       onclick="event.preventDefault(); localStorage.removeItem('refresh_token'); localStorage.removeItem('remember_me'); localStorage.removeItem('jwt_expires_in'); fetch('/api/v1/auth/logout', {method:'POST', credentials:'include', headers:{'Accept':'text/html'}}).then(()=>window.location.href='/login');">
                        {{ logout_label }}
                    </a>
                    {% else %}
                    <a href="/profile" class="btn btn-ghost">{{ cancel }}</a>
                    {% endif %}
                    <button type="submit" class="btn btn-error" id="submitBtn" disabled>
                        <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 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
                        {{ change_button }}
                    </button>
                </div>
            </form>
        </div>
    </div>

    {% if must_change %}
    </div>
</div>
    {% endif %}

    <!-- Security Tips Card -->
    {% if !must_change %}
    <div class="card bg-base-200 shadow-xl max-w-2xl mt-6">
        <div class="card-body">
            <h3 class="card-title text-base">
                <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="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>
                {{ security_tips_title }}
            </h3>
            <ul class="list-disc list-inside text-sm space-y-2 text-base-content/80">
                <li>{{ security_tip_unique }}</li>
                <li>{{ security_tip_share }}</li>
                <li>{{ security_tip_regular }}</li>
                <li>{{ security_tip_2fa }}</li>
                <li>{{ security_tip_manager }}</li>
            </ul>
        </div>
    </div>
    {% endif %}

{% if !must_change %}
</div>
{% endif %}

<script>
const newPasswordInput = document.getElementById('newPassword');
const confirmPasswordInput = document.getElementById('confirmPassword');
const submitBtn = document.getElementById('submitBtn');
const strengthBar = document.getElementById('strengthBar');
const strengthProgress = document.getElementById('strengthProgress');
const strengthText = document.getElementById('strengthText');
const matchIndicator = document.getElementById('matchIndicator');

// Password rules from app_settings (injected by server)
const PW_MIN_LENGTH = {{ pw_min_length }};
const PW_REQ_UPPER  = {{ pw_require_uppercase }};
const PW_REQ_LOWER  = {{ pw_require_lowercase }};
const PW_REQ_DIGITS = {{ pw_require_digits }};
const PW_REQ_SPECIAL= {{ pw_require_special }};

function checkPasswordStrength(password) {
    let strength = 0;
    let feedback = [];
    const totalChecks = 1
        + (PW_REQ_UPPER  ? 1 : 0)
        + (PW_REQ_LOWER  ? 1 : 0)
        + (PW_REQ_DIGITS ? 1 : 0)
        + (PW_REQ_SPECIAL? 1 : 0);
    const pointsPerCheck = Math.floor(100 / (totalChecks + 1));

    if (password.length >= PW_MIN_LENGTH) strength += pointsPerCheck;
    if (password.length >= PW_MIN_LENGTH + 4) strength += pointsPerCheck;
    if (password.length < PW_MIN_LENGTH) feedback.push('{{ strength_too_short }}');

    if (PW_REQ_LOWER) {
        if (/[a-z]/.test(password)) strength += pointsPerCheck; else feedback.push('{{ strength_missing_lowercase }}');
    }
    if (PW_REQ_UPPER) {
        if (/[A-Z]/.test(password)) strength += pointsPerCheck; else feedback.push('{{ strength_missing_uppercase }}');
    }
    if (PW_REQ_DIGITS) {
        if (/[0-9]/.test(password)) strength += pointsPerCheck; else feedback.push('{{ strength_missing_digit }}');
    }
    if (PW_REQ_SPECIAL) {
        if (/[^a-zA-Z0-9]/.test(password)) strength += pointsPerCheck; else feedback.push('{{ strength_missing_special }}');
    }

    if (!PW_REQ_LOWER  && /[a-z]/.test(password))        strength += 5;
    if (!PW_REQ_UPPER  && /[A-Z]/.test(password))        strength += 5;
    if (!PW_REQ_DIGITS && /[0-9]/.test(password))        strength += 5;
    if (!PW_REQ_SPECIAL&& /[^a-zA-Z0-9]/.test(password)) strength += 5;

    strength = Math.min(strength, 100);
    return { strength, feedback };
}

// Update password strength indicator
newPasswordInput.addEventListener('input', function() {
    const password = this.value;
    
    if (password.length > 0) {
        strengthBar.classList.remove('hidden');
        const { strength, feedback } = checkPasswordStrength(password);
        
        strengthProgress.value = strength;
        
        if (strength < 40) {
            strengthProgress.className = 'progress progress-error w-full';
            strengthText.textContent = '{{ strength_weak }} (' + feedback.join(', ') + ')';
            strengthText.className = 'label-text-alt font-semibold text-error';
        } else if (strength < 70) {
            strengthProgress.className = 'progress progress-warning w-full';
            strengthText.textContent = '{{ strength_medium }}' + (feedback.length ? ' (' + feedback.join(', ') + ')' : '');
            strengthText.className = 'label-text-alt font-semibold text-warning';
        } else {
            strengthProgress.className = 'progress progress-success w-full';
            strengthText.textContent = '{{ strength_strong }}';
            strengthText.className = 'label-text-alt font-semibold text-success';
        }
    } else {
        strengthBar.classList.add('hidden');
    }
    
    checkFormValidity();
});

// Check password match
confirmPasswordInput.addEventListener('input', function() {
    if (this.value.length > 0) {
        if (this.value === newPasswordInput.value) {
            matchIndicator.textContent = '{{ passwords_match }}';
            matchIndicator.className = 'label-text-alt text-success font-semibold';
        } else {
            matchIndicator.textContent = '{{ passwords_not_match }}';
            matchIndicator.className = 'label-text-alt text-error font-semibold';
        }
    } else {
        matchIndicator.textContent = '';
    }
    
    checkFormValidity();
});

// Enable submit button only if form is valid
function checkFormValidity() {
    const oldPassword = document.getElementById('oldPassword').value;
    const newPassword = newPasswordInput.value;
    const confirmPassword = confirmPasswordInput.value;
    
    const { strength, feedback } = checkPasswordStrength(newPassword);
    const isValid = oldPassword.length > 0 && 
                   newPassword.length >= PW_MIN_LENGTH && 
                   confirmPassword === newPassword &&
                   feedback.length === 0;
    
    submitBtn.disabled = !isValid;
}

// Form submission
document.getElementById('passwordForm').addEventListener('submit', async function(e) {
    e.preventDefault();
    
    const formData = new FormData(this);
    submitBtn.disabled = true;
    submitBtn.innerHTML = '<span class="loading loading-spinner"></span> {{ changing }}';
    
    try {
        // Convert FormData to JSON object
        const oldPassword = formData.get('old_password');
        const newPassword = formData.get('new_password');
        const confirmPassword = formData.get('confirm_password');
        
        // Validate that all fields are present
        if (!oldPassword || !newPassword || !confirmPassword) {
            alert('❌ {{ error_all_fields_required }}');
            submitBtn.disabled = false;
            submitBtn.innerHTML = '<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 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> {{ change_button }}';
            return;
        }
        
        const jsonData = {
            old_password: oldPassword.toString(),
            new_password: newPassword.toString(),
            confirm_password: confirmPassword.toString()
        };
        
        const jsonBody = JSON.stringify(jsonData);
        
        // Validate JSON is not empty
        if (!jsonBody || jsonBody === '{}' || jsonBody.length === 0) {
            alert('❌ {{ error_empty_data }}');
            submitBtn.disabled = false;
            submitBtn.innerHTML = '<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 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> {{ change_button }}';
            return;
        }
        
        console.log('Sending password change request:', {
            method: 'PUT',
            url: '/settings/password',
            headers: { 'Content-Type': 'application/json' },
            body: jsonBody,
            bodyLength: jsonBody.length
        });
        
        const response = await fetch('/settings/password', {
            method: 'PUT',
            headers: {
                'Content-Type': 'application/json',
                'Accept': 'application/json',
                'X-CSRF-Token': '{{ csrf_token }}',
            },
            credentials: 'include',
            body: jsonBody
        });
        
        console.log('Response status:', response.status);
        console.log('Response headers:', Object.fromEntries(response.headers.entries()));
        
        // Clone response to read it multiple times if needed
        const responseClone = response.clone();
        
        let result = {};
        let errorText = '';
        
        // Try to parse as JSON first
        try {
            const contentType = response.headers.get('content-type') || '';
            if (contentType.includes('application/json')) {
                result = await response.json();
            } else {
                // Not JSON, read as text
                errorText = await responseClone.text();
                console.error('Non-JSON response (status ' + response.status + '):', errorText);
            }
        } catch (e) {
            // Failed to parse JSON, try text
            try {
                errorText = await responseClone.text();
                console.error('Failed to parse response (status ' + response.status + '):', errorText);
            } catch (e2) {
                console.error('Failed to read response:', e2);
                errorText = 'Erreur lors de la lecture de la réponse du serveur';
            }
        }
        
        const savedEmailMsg = "{{ saved }}";
        const savedDirectMsg = "{{ saved_direct }}";
        const errorAllFields = "{{ error_all_fields_required }}";
        const errorEmpty = "{{ error_empty_data }}";
        const errorInvalid = "{{ error_invalid_data }}";
        const errorIncorrect = "{{ error_incorrect_current }}";
        const error415 = "{{ error_415 }}";
        const networkErr = "{{ network_error }}";
        const changeBtnText = "{{ change_button }}";
        
        if (response.ok) {
            const msgKey = result && result.message;
            if (msgKey === 'email_confirmation_sent') {
                alert('✅ ' + savedEmailMsg);
            } else {
                alert('✅ ' + savedDirectMsg);
            }
            window.location.href = '/settings';
        } else {
            let errorMessage = 'Échec du changement de mot de passe';
            
            if (response.status === 415) {
                errorMessage = error415;
                console.error('415 Error details:', errorText || 'No error text available');
            } else if (result && result.error) {
                errorMessage = result.error;
            } else if (result && result.message) {
                errorMessage = result.message;
            } else if (errorText) {
                errorMessage = errorText;
            } else if (response.status === 400) {
                errorMessage = errorInvalid;
            } else if (response.status === 401) {
                errorMessage = errorIncorrect;
            }
            
            alert('❌ ' + errorMessage);
            submitBtn.disabled = false;
            submitBtn.innerHTML = '<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 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> ' + changeBtnText;
        }
    } catch (error) {
        console.error('Error:', error);
        alert('❌ ' + networkErr);
        submitBtn.disabled = false;
        submitBtn.innerHTML = '<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 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> ' + changeBtnText;
    }
});

// Initialize form check
document.getElementById('oldPassword').addEventListener('input', checkFormValidity);

// Feature 026: Cancel pending password change request
async function cancelPendingRequest() {
    const cancelBtn = document.getElementById('cancelPendingRequestBtn');
    if (!cancelBtn) return;
    
    if (!confirm('{{ pending_request_cancel }} ?')) {
        return;
    }
    
    cancelBtn.disabled = true;
    cancelBtn.innerHTML = '<span class="loading loading-spinner"></span>';
    
    try {
        const response = await fetch('/settings/password/pending', {
            method: 'DELETE',
            headers: {
                'Content-Type': 'application/json',
                'X-CSRF-Token': '{{ csrf_token }}',
            },
            credentials: 'same-origin'
        });
        
        if (response.ok) {
            // Reload page to update UI
            window.location.reload();
        } else {
            const result = await response.json().catch(() => ({}));
            const errorMessage = result.message || result.error || '{{ network_error }}';
            alert('❌ ' + errorMessage);
            cancelBtn.disabled = false;
            cancelBtn.innerHTML = '{{ pending_request_cancel }}';
        }
    } catch (error) {
        console.error('Error canceling pending request:', error);
        alert('❌ {{ network_error }}');
        cancelBtn.disabled = false;
        cancelBtn.innerHTML = '{{ pending_request_cancel }}';
    }
}
</script>
{% endblock %}

{% block footer %}
{% if !must_change %}
<footer class="bg-base-200 border-t border-base-300 py-4 mt-8">
    <div class="container mx-auto px-4 text-center text-base-content text-sm">
        <p>{{ app_name }} {{ app_version }}{% if app_domain != "" %} - {{ app_domain }}{% endif %}</p>
        {% if app_description != "" %}
        <p class="text-base-content/70 mt-1">{{ app_description }}</p>
        {% endif %}
    </div>
</footer>
{% endif %}
{% endblock %}