login.html 644 lignes · 28465 octets
<!DOCTYPE html>
<html lang="{{ current_locale }}">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta name="description" content="{{ page_description }}">
    <title>{{ page_title }}</title>
    
    <!-- Tailwind CSS (local) -->
    <link href="/static/css/tailwind.css" rel="stylesheet">
    
    <!-- Custom CSS -->
    <link href="/static/css/custom.css" rel="stylesheet">
    
    <!-- HTMX (local) -->
    <script src="/static/js/htmx.min.js"></script>
    
    <!-- Alpine.js (local) -->
    <script src="/static/js/alpine-components.js" defer></script>
    <script src="/static/js/alpine-csp.min.js" defer></script>
</head>
<body class="bg-base-200 min-h-screen flex items-center justify-center px-4">
    <div class="max-w-md w-full">
        <!-- Language Selector (Feature 014 - T065) -->
        <div class="flex justify-end mb-4">
            <div class="dropdown dropdown-end">
                <label tabindex="0" class="btn btn-ghost btn-sm gap-2">
                    <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="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
                    </svg>
                    <span id="current-lang-display">
                        {% for (code, name, is_current) in supported_languages %}
                            {% if is_current %}{{ name }}{% endif %}
                        {% endfor %}
                    </span>
                    <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 9l-7 7-7-7" />
                    </svg>
                </label>
                <ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-[1] w-52 p-2 shadow">
                    {% for (code, name, is_current) in supported_languages %}
                    <li>
                        <a href="#" onclick="return window.changeLanguage('{{ code }}');" class="{% if is_current %}active{% endif %}">
                            {{ name }}
                            {% if is_current %}
                            <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
                                <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd" />
                            </svg>
                            {% endif %}
                        </a>
                    </li>
                    {% endfor %}
                </ul>
            </div>
        </div>
        
        <!-- Logo and Title -->
        <div class="text-center mb-8">
            <div class="inline-flex items-center justify-center w-16 h-16 mb-4">
                <img src="/static/images/logo.svg" alt="{{ app_name }}" class="h-16 w-16">
            </div>
            <h1 class="text-3xl font-bold text-base-content mb-2">{{ app_name }}</h1>
            <p class="text-base-content/70">{{ subtitle }}</p>
        </div>
        
        <!-- Login/Register Form Card -->
        <div class="card bg-base-100 shadow-xl p-8">
            <!-- Tabs for Login/Register -->
            <div class="tabs tabs-boxed mb-6">
                <a id="tab-login" class="tab tab-active" onclick="showLogin()">{{ tab_login }}</a>
                <a id="tab-register" class="tab" onclick="showRegister()">{{ tab_register }}</a>
            </div>
            
            <!-- Login Section -->
            <div id="login-section">
                <h2 class="text-2xl font-semibold text-base-content mb-6 text-center">{{ login_title }}</h2>
            
            <!-- Error Message (if any) -->
            <div id="error-message" role="alert" class="hidden mb-4 alert alert-error">
                <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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
                <span id="error-text"></span>
            </div>
            
            <!-- Success Message (if any) -->
            <div id="success-message" role="alert" class="hidden mb-4 alert alert-success">
                <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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
                <span id="success-text"></span>
            </div>
            
            <!-- Resend verification CTA -->
            <div id="resend-block" class="hidden mb-4 space-y-2">
                <p class="text-sm text-base-content">{{ email_unverified_message }}</p>
                <button id="resend-btn" type="button" class="btn btn-outline btn-primary w-full">{{ resend_button }}</button>
                <p id="resend-status" class="text-sm text-success hidden"></p>
            </div>
            
            <!-- Login Form -->
            <form 
                id="login-form"
                class="space-y-4">
                
                <!-- Username or Email -->
                <div class="form-control">
                    <label for="username" class="label">
                        <span class="label-text">{{ username_label }}</span>
                    </label>
                    <input
                        type="text"
                        id="username"
                        name="username"
                        required
                        autocomplete="username email"
                        class="input input-bordered w-full"
                        placeholder="{{ username_placeholder }}">
                </div>
                
                <!-- Password -->
                <div class="form-control">
                    <label for="password" class="label">
                        <span class="label-text">{{ password_label }}</span>
                    </label>
                    <input 
                        type="password" 
                        id="password" 
                        name="password" 
                        required
                        autocomplete="current-password"
                        class="input input-bordered w-full"
                        placeholder="{{ password_placeholder }}">
                </div>
                
                <!-- Remember Me (optional) -->
                <div class="form-control">
                    <label class="label cursor-pointer justify-start gap-2">
                    <input 
                        type="checkbox" 
                        id="remember" 
                        name="remember"
                            class="checkbox checkbox-primary">
                        <span class="label-text">{{ remember_me }}</span>
                    </label>
                </div>
                
                <!-- Submit Button -->
                <div class="form-control mt-6">
                <button 
                    type="submit"
                        class="btn btn-primary w-full">
                    {{ submit_button }}
                </button>
                </div>
            </form>
            
                <!-- Additional Links -->
                <div class="mt-6 text-center space-y-2">
                    <a href="/forgot-password" class="text-sm text-primary hover:underline">
                        {{ forgot_password }}
                    </a>
                    <p class="text-sm text-base-content/70">
                        {{ email_not_verified }}
                        <a href="/resend-verification" class="text-primary hover:underline">
                            {{ resend_verification }}
                        </a>
                    </p>
                </div>
            </div>
            
            <!-- Register Section -->
            <div id="register-section" class="hidden">
                <h2 class="text-2xl font-semibold text-base-content mb-6 text-center">{{ register_title }}</h2>
                
                <!-- Error Message (if any) -->
                <div id="register-error-message" role="alert" class="hidden mb-4 alert alert-error">
                    <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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
                    <span id="register-error-text"></span>
                </div>
                
                <!-- Success Message (if any) -->
                <div id="register-success-message" role="alert" class="hidden mb-4 alert alert-success">
                    <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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
                    <span id="register-success-text"></span>
                </div>
                
                <!-- Register Form -->
                <form id="register-form" class="space-y-4">
                    <!-- Username -->
                    <div class="form-control">
                        <label for="register-username" class="label">
                            <span class="label-text">{{ username_label }}</span>
                        </label>
                        <input 
                            type="text" 
                            id="register-username" 
                            name="username" 
                            required
                            minlength="3"
                            maxlength="39"
                            pattern="[a-z0-9-]+"
                            title="{{ username_pattern_register }}"
                            autocomplete="username"
                            class="input input-bordered w-full"
                            placeholder="{{ username_placeholder_register }}">
                    </div>
                    
                    <!-- Email -->
                    <div class="form-control">
                        <label for="register-email" class="label">
                            <span class="label-text">{{ email_label }}</span>
                        </label>
                        <input 
                            type="email" 
                            id="register-email" 
                            name="email" 
                            required
                            autocomplete="email"
                            class="input input-bordered w-full"
                            placeholder="{{ email_placeholder }}">
                    </div>
                    
                    <!-- Password -->
                    <div class="form-control">
                        <label for="register-password" class="label">
                            <span class="label-text">{{ password_label }}</span>
                        </label>
                        <input 
                            type="password" 
                            id="register-password" 
                            name="password" 
                            required
                            minlength="{{ pw_min_length }}"
                            autocomplete="new-password"
                            class="input input-bordered w-full"
                            placeholder="{{ password_placeholder_register }}">
                    </div>
                    
                    <!-- Submit Button -->
                    <div class="form-control mt-6">
                        <button 
                            type="submit"
                            class="btn btn-primary w-full">
                            {{ register_submit }}
                        </button>
                    </div>
                </form>
                
                <!-- Additional Links -->
                <div class="mt-6 text-center">
                    <p class="text-sm text-base-content/70">
                        {{ already_have_account }}
                        <a href="#" onclick="showLogin(); return false;" class="text-primary hover:underline">
                            {{ sign_in_link }}
                        </a>
                    </p>
                </div>
            </div>
        </div>
        
        <!-- Footer -->
        <div class="mt-8 text-center text-sm text-base-content/50">
            <p>{{ app_name }} v{{ app_version }} - {{ footer_text }}</p>
        </div>
    </div>
    
    <script>
        // Translated messages (Feature 014 - i18n login page)
        const i18nMessages = {
            successMessage: "{{ success_message }}",
            errorMessage: "{{ error_message }}",
            resendSuccess: "{{ resend_success }}",
            resendError: "{{ resend_error }}",
            resendPrompt: "{{ resend_prompt }}",
            registerSuccess: "{{ register_success }}",
            registerError: "{{ register_error }}",
        };
        
        // Tab switching functions
        function showLogin() {
            document.getElementById('login-section').classList.remove('hidden');
            document.getElementById('register-section').classList.add('hidden');
            document.getElementById('tab-login').classList.add('tab-active');
            document.getElementById('tab-register').classList.remove('tab-active');
        }
        
        function showRegister() {
            document.getElementById('login-section').classList.add('hidden');
            document.getElementById('register-section').classList.remove('hidden');
            document.getElementById('tab-login').classList.remove('tab-active');
            document.getElementById('tab-register').classList.add('tab-active');
        }
        
        // SEC-H2: Validate redirect parameter to prevent open redirect attacks
        function getSafeRedirect() {
            const urlParams = new URLSearchParams(window.location.search);
            const redirect = urlParams.get('redirect') || '/home';
            try {
                const url = new URL(redirect, window.location.origin);
                if (url.origin === window.location.origin && url.pathname.startsWith('/')) {
                    return url.pathname + url.search + url.hash;
                }
            } catch (e) { /* invalid URL — fall through */ }
            return '/home';
        }

        function setErrorMessage(message) {
            const errorDiv = document.getElementById('error-message');
            const errorText = document.getElementById('error-text');
            if (errorDiv && errorText) {
                errorText.textContent = message || i18nMessages.errorMessage;
                errorDiv.classList.remove('hidden');
                if (isUnverifiedMessage(message)) {
                    showResendBlock();
                }
            }
        }

        function normalizeText(text) {
            return text
                .normalize('NFD')
                .replace(/\p{Diacritic}/gu, '')
                .toLowerCase();
        }

        function isUnverifiedMessage(msg) {
            if (!msg) return false;
            const lower = normalizeText(msg);
            return (
                lower.includes('non verifie') ||
                lower.includes('not verified') ||
                (lower.includes('email') && lower.includes('verifi'))
            );
        }

        // Handle login response
        function handleLoginResponse(event) {
            const xhr = event.detail.xhr;
            
            if (xhr.status === 200) {
                // Login successful
                const response = JSON.parse(xhr.responseText);

                // Check if 2FA is required
                if (response.requires_2fa && response.challenge_token) {
                    window.location.href = '/login/2fa?challenge=' + encodeURIComponent(response.challenge_token);
                    return;
                }

                // Hide error message if visible
                const errorDiv = document.getElementById('error-message');
                if (errorDiv) {
                    errorDiv.classList.add('hidden');
                }
                
                // Show success message
                const successDiv = document.getElementById('success-message');
                if (successDiv) {
                    document.getElementById('success-text').textContent = i18nMessages.successMessage;
                    successDiv.classList.remove('hidden');
                }
                
                // SEC-H2: safe redirect after login
                setTimeout(() => {
                    window.location.href = getSafeRedirect();
                }, 500);
            } else {
                // Login failed
                    try {
                        const errorResponse = JSON.parse(xhr.responseText);
                    const message =
                        errorResponse.error ||
                        errorResponse.message ||
                            i18nMessages.errorMessage;
                    setErrorMessage(message);
                    if (isUnverifiedMessage(message)) {
                        showResendBlock();
                    }
                } catch (e) {
                    setErrorMessage(i18nMessages.errorMessage);
                }
            }
        }
        
        // Handle form submission with JSON
        document.getElementById('login-form').addEventListener('submit', function(e) {
            e.preventDefault();
            
            const username = document.getElementById('username').value;
            const password = document.getElementById('password').value;
            
            // Hide previous error
            const errorDiv = document.getElementById('error-message');
            if (errorDiv) {
                errorDiv.classList.add('hidden');
            }
            hideResendBlock();
            
            const rememberMe = document.getElementById('remember').checked;

            // Create JSON payload
            const payload = JSON.stringify({
                username: username,
                password: password,
                remember_me: rememberMe
            });
            
            // Use fetch API to send JSON request
            fetch('/api/v1/auth/login', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: payload,
                credentials: 'include' // Include cookies for session
            })
            .then(async response => {
                if (response.ok) {
                    return response.json();
                } else {
                    let msg = i18nMessages.errorMessage;
                    try {
                        const err = await response.json();
                        msg = err.error || err.message || msg;
                    } catch (_) {
                        // ignore parse errors
                    }
                    throw new Error(msg);
                }
            })
            .then(data => {
                // Check if 2FA is required
                if (data.requires_2fa && data.challenge_token) {
                    window.location.href = '/login/2fa?challenge=' + encodeURIComponent(data.challenge_token);
                    return;
                }

                // Login successful
                const successDiv = document.getElementById('success-message');
                if (successDiv) {
                    document.getElementById('success-text').textContent = i18nMessages.successMessage;
                    successDiv.classList.remove('hidden');
                }

                // Store refresh token and remember_me flag for auto-refresh
                if (data.refresh_token) {
                    localStorage.setItem('refresh_token', data.refresh_token);
                }
                if (rememberMe) {
                    localStorage.setItem('remember_me', 'true');
                    // Store expires_in so auto-refresh on next page knows the interval
                    if (data.expires_in) {
                        localStorage.setItem('jwt_expires_in', String(data.expires_in));
                    }
                } else {
                    localStorage.removeItem('remember_me');
                }

                // T130: Check if user must change password
                if (data.user && data.user.must_change_password === true) {
                    // Redirect to password change page with must_change flag
                    setTimeout(() => {
                        window.location.href = '/settings/password?must_change=true';
                    }, 500);
                    return;
                }

                // Check if user must setup 2FA
                if (data.user && data.user.must_setup_2fa === true) {
                    setTimeout(() => {
                        window.location.href = '/settings/security?force_2fa=true';
                    }, 500);
                    return;
                }

                // SEC-H2: safe redirect after login
                setTimeout(() => {
                    window.location.href = getSafeRedirect();
                }, 500);
            })
            .catch(error => {
                const msg = error.message || i18nMessages.errorMessage;
                setErrorMessage(msg);
                if (isUnverifiedMessage(msg)) {
                    showResendBlock();
                }
            });
        });

        // Register form handler
        document.getElementById('register-form').addEventListener('submit', function(e) {
            e.preventDefault();
            
            const username = document.getElementById('register-username').value;
            const email = document.getElementById('register-email').value;
            const password = document.getElementById('register-password').value;
            
            // Hide previous messages
            const errorDiv = document.getElementById('register-error-message');
            const successDiv = document.getElementById('register-success-message');
            if (errorDiv) errorDiv.classList.add('hidden');
            if (successDiv) successDiv.classList.add('hidden');
            
            // Create JSON payload
            const payload = JSON.stringify({
                username: username,
                email: email,
                password: password
            });
            
            // Send registration request
            fetch('/api/v1/auth/register', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json',
                },
                body: payload,
                credentials: 'include'
            })
            .then(async response => {
                if (response.ok) {
                    return response.json();
                } else {
                    let msg = i18nMessages.registerError;
                    try {
                        const err = await response.json();
                        msg = err.error || err.message || msg;
                    } catch (_) {
                        // ignore parse errors
                    }
                    throw new Error(msg);
                }
            })
            .then(data => {
                // Registration successful
                if (successDiv) {
                    document.getElementById('register-success-text').textContent = 
                        data.message || i18nMessages.registerSuccess;
                    successDiv.classList.remove('hidden');
                }
                
                // Clear form
                document.getElementById('register-form').reset();
                
                // Optionally redirect to login after 3 seconds
                setTimeout(() => {
                    showLogin();
                    if (successDiv) successDiv.classList.add('hidden');
                }, 3000);
            })
            .catch(error => {
                const msg = error.message || i18nMessages.registerError;
                if (errorDiv) {
                    document.getElementById('register-error-text').textContent = msg;
                    errorDiv.classList.remove('hidden');
                }
            });
        });
        
        function showResendBlock() {
            const block = document.getElementById('resend-block');
            if (block) {
                block.classList.remove('hidden');
                block.style.display = 'block';
            }
        }

        function hideResendBlock() {
            const block = document.getElementById('resend-block');
            const status = document.getElementById('resend-status');
            if (block) {
                block.classList.add('hidden');
                block.style.display = 'none';
            }
            if (status) {
                status.textContent = '';
                status.classList.add('hidden');
            }
        }

        document.getElementById('resend-btn')?.addEventListener('click', () => {
            const emailOrUsername = document.getElementById('username').value;
            const status = document.getElementById('resend-status');
            if (!emailOrUsername) {
                const errorDiv = document.getElementById('error-message');
                if (errorDiv) {
                    document.getElementById('error-text').textContent = i18nMessages.resendPrompt;
                    errorDiv.classList.remove('hidden');
                }
                return;
            }
            const isEmail = emailOrUsername.includes('@');
            const endpoint = isEmail
                ? '/api/v1/auth/resend-verification'
                : '/api/v1/auth/resend-verification/by-username';
            const payload = isEmail
                ? { email: emailOrUsername }
                : { username: emailOrUsername };
            fetch(endpoint, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify(payload),
                credentials: 'include'
            })
            .then(response => response.json())
            .then(data => {
                if (status) {
                    status.textContent = data.message || i18nMessages.resendSuccess;
                    status.classList.remove('hidden');
                }
            })
            .catch(() => {
                if (status) {
                    status.textContent = i18nMessages.resendError;
                    status.classList.remove('hidden');
                }
            });
        });
        
        // Language selector functionality (Feature 014 - T065)
        // changeLanguage is defined globally in base.html
        // For login page (which doesn't extend base.html), define it here
        if (typeof window.changeLanguage === 'undefined') {
            window.changeLanguage = function(locale) {
                console.log('changeLanguage called with locale:', locale);
                
                if (!['fr', 'en', 'de', 'es'].includes(locale)) {
                    console.error('Invalid locale:', locale);
                    return false;
                }
                
                // Set cookie with locale (expires in 1 year)
                document.cookie = `locale=${locale}; path=/; max-age=31536000; SameSite=Lax`;
                console.log('Cookie set to:', locale);
                
                // Reload page to apply new language
                window.location.reload();
                return false;
            };
        }
    </script>
</body>
</html>