// login.js document.addEventListener("DOMContentLoaded", () => { const form = document.getElementById("login-form"); const errorBox = document.getElementById("error"); const usernameInput = document.getElementById("username"); const passwordInput = document.getElementById("password"); const twoFactorInput = document.getElementById("two-factor-code"); const twoFactorGroup = document.getElementById("two-factor-group"); const submitButton = document.getElementById("login-submit"); const passkeyLoginButton = document.getElementById("passkey-login-button"); const passkeyLoginHint = document.getElementById("passkey-login-hint"); let pendingTwoFactorToken = null; if (!form) return; setupPasswordVisibilityToggles(); function showError(message) { errorBox.textContent = message || "Login failed."; errorBox.style.display = "block"; } function clearError() { errorBox.textContent = ""; errorBox.style.display = "none"; } function setupPasswordVisibilityToggles(root = document) { const eyeIcon = ` `; const eyeOffIcon = ` `; root.querySelectorAll('input[type="password"]').forEach((input) => { if (input.dataset.visibilityToggleAttached === 'true') return; input.dataset.visibilityToggleAttached = 'true'; let wrapper = input.closest('.password-input-wrapper'); if (!wrapper) { wrapper = document.createElement('div'); wrapper.className = 'password-input-wrapper'; input.parentNode.insertBefore(wrapper, input); wrapper.appendChild(input); } const toggle = document.createElement('button'); toggle.type = 'button'; toggle.className = 'password-toggle'; toggle.innerHTML = eyeIcon; toggle.setAttribute('aria-label', `Show ${input.placeholder || 'password'}`); toggle.setAttribute('title', 'Show password'); toggle.addEventListener('click', () => { const show = input.type === 'password'; input.type = show ? 'text' : 'password'; toggle.innerHTML = show ? eyeOffIcon : eyeIcon; toggle.setAttribute('aria-label', `${show ? 'Hide' : 'Show'} ${input.placeholder || 'password'}`); toggle.setAttribute('title', show ? 'Hide password' : 'Show password'); }); wrapper.appendChild(toggle); }); } function storeTokens(data) { localStorage.setItem("access_token", data.access_token); localStorage.setItem("refresh_token", data.refresh_token); } function switchToTwoFactorMode(token) { pendingTwoFactorToken = token; usernameInput.disabled = true; passwordInput.disabled = true; if (passkeyLoginButton) passkeyLoginButton.disabled = true; twoFactorGroup.style.display = "block"; twoFactorInput.required = true; twoFactorInput.focus(); submitButton.textContent = "Verify code"; } function webauthnSupported() { return window.PublicKeyCredential && navigator.credentials; } function base64URLToBuffer(value) { const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "="); const binary = atob(padded); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } function bufferToBase64URL(buffer) { const bytes = new Uint8Array(buffer); let binary = ""; for (const byte of bytes) { binary += String.fromCharCode(byte); } return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); } function prepareCredentialRequestOptions(options) { const publicKey = options.publicKey || options; publicKey.challenge = base64URLToBuffer(publicKey.challenge); if (Array.isArray(publicKey.allowCredentials)) { publicKey.allowCredentials = publicKey.allowCredentials.map((credential) => ({ ...credential, id: base64URLToBuffer(credential.id) })); } return publicKey; } function credentialToJSON(credential) { const response = credential.response; return { id: credential.id, rawId: bufferToBase64URL(credential.rawId), type: credential.type, response: { clientDataJSON: bufferToBase64URL(response.clientDataJSON), authenticatorData: bufferToBase64URL(response.authenticatorData), signature: bufferToBase64URL(response.signature), userHandle: response.userHandle ? bufferToBase64URL(response.userHandle) : null }, clientExtensionResults: credential.getClientExtensionResults() }; } async function handlePasskeyLogin() { if (!webauthnSupported()) { showError("Passkeys are not supported by this browser."); return; } clearError(); if (passkeyLoginButton) passkeyLoginButton.disabled = true; submitButton.disabled = true; try { const optionsResponse = await fetch("/api/passkeys/login/options", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) }); if (!optionsResponse.ok) { throw new Error(await optionsResponse.text()); } const optionsData = await optionsResponse.json(); const publicKey = prepareCredentialRequestOptions(optionsData.options); const assertion = await navigator.credentials.get({ publicKey }); if (!assertion) { throw new Error("Passkey authentication was cancelled."); } const finishResponse = await fetch("/api/passkeys/login/finish", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_token: optionsData.session_token, credential: credentialToJSON(assertion) }) }); if (!finishResponse.ok) { throw new Error(await finishResponse.text()); } const data = await finishResponse.json(); if (data.requires_2fa) { switchToTwoFactorMode(data.two_factor_token); return; } storeTokens(data); window.location.href = "/dashboard"; } catch (err) { showError(err.message); } finally { if (!pendingTwoFactorToken && passkeyLoginButton) passkeyLoginButton.disabled = false; submitButton.disabled = false; } } if (passkeyLoginButton) { if (!webauthnSupported()) { passkeyLoginButton.disabled = true; if (passkeyLoginHint) passkeyLoginHint.textContent = "Passkeys are not supported by this browser."; } else { passkeyLoginButton.addEventListener("click", handlePasskeyLogin); } } form.addEventListener("submit", async (e) => { e.preventDefault(); clearError(); submitButton.disabled = true; try { let response; if (pendingTwoFactorToken) { response = await fetch("/api/login/2fa", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ two_factor_token: pendingTwoFactorToken, code: twoFactorInput.value.trim() }) }); } else { response = await fetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username: usernameInput.value, password: passwordInput.value }) }); } if (!response.ok) { const text = await response.text(); throw new Error(text); } const data = await response.json(); if (data.requires_2fa) { switchToTwoFactorMode(data.two_factor_token); return; } storeTokens(data); window.location.href = "/dashboard"; } catch (err) { showError(err.message); } finally { submitButton.disabled = false; } }); });