All checks were successful
test-and-lint / test-and-lint (pull_request) Successful in 2m50s
254 lines
10 KiB
JavaScript
254 lines
10 KiB
JavaScript
// 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 = `
|
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
|
<path d="M2.25 12s3.5-6.75 9.75-6.75S21.75 12 21.75 12 18.25 18.75 12 18.75 2.25 12 2.25 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
|
<circle cx="12" cy="12" r="2.75" fill="none" stroke="currentColor" stroke-width="1.8"/>
|
|
</svg>`;
|
|
const eyeOffIcon = `
|
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
|
<path d="M3 3l18 18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
<path d="M10.58 10.58A2.75 2.75 0 0 0 13.42 13.42" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
|
|
<path d="M7.1 7.7C3.95 9.55 2.25 12 2.25 12s3.5 6.75 9.75 6.75c1.65 0 3.08-.47 4.29-1.15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
|
<path d="M9.8 5.55A9.2 9.2 0 0 1 12 5.25c6.25 0 9.75 6.75 9.75 6.75a15.3 15.3 0 0 1-2.3 2.95" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>
|
|
</svg>`;
|
|
|
|
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;
|
|
}
|
|
});
|
|
});
|