// 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;
}
});
});