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>
GitRust