added passkey support (closes #6)
All checks were successful
test-and-lint / test-and-lint (pull_request) Successful in 2m50s

This commit is contained in:
2026-06-10 03:24:31 +02:00
parent 01ec41288a
commit fb3be56959
18 changed files with 1680 additions and 61 deletions

View File

@@ -130,6 +130,51 @@ input::placeholder {
color: #4b5563;
}
.password-input-wrapper {
position: relative;
width: 100%;
}
.password-input-wrapper input {
padding-right: 3rem;
}
.password-toggle {
position: absolute;
top: 50%;
right: 0.45rem;
width: 2.25rem;
height: 2.25rem;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
cursor: pointer;
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.password-toggle:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.06);
}
.password-toggle:focus-visible {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.password-toggle svg {
width: 1.15rem;
height: 1.15rem;
pointer-events: none;
}
.footer-text {
margin-top: 1.5rem;
font-size: 0.9rem;

View File

@@ -41,6 +41,7 @@ document.addEventListener("DOMContentLoaded", () => {
if (document.getElementById('projects-table-body')) loadProjects();
if (document.getElementById('account-settings-content')) loadAccountSettings();
setupPasswordVisibilityToggles();
loadProfile();
});
@@ -487,6 +488,7 @@ async function loadProfile() {
// ---- ACCOUNT SETTINGS ----
let latestRecoveryCodes = [];
let pendingTwoFactorSetupToken = "";
let currentPasskeys = [];
function showAccountSettingsMessage(message, type = 'success') {
const box = document.getElementById('account-settings-message');
@@ -496,6 +498,51 @@ function showAccountSettingsMessage(message, type = 'success') {
box.style.display = 'block';
}
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 setTwoFactorPanels(enabled) {
const badge = document.getElementById('two-factor-badge');
const status = document.getElementById('two-factor-status');
@@ -552,16 +599,17 @@ async function loadAccountSettings() {
const data = await apiRequest('/api/profile');
const usernameInput = document.getElementById('settings-username');
const avatarPreview = document.getElementById('settings-avatar-preview');
const remaining = document.getElementById('recovery-codes-remaining');
if (usernameInput) usernameInput.value = data.username || '';
if (avatarPreview && data.username) avatarPreview.innerText = data.username[0].toLocaleUpperCase();
if (remaining) remaining.innerText = data.recovery_codes_remaining || 0;
updateRecoveryCodeWarning(data.recovery_codes_remaining || 0, !!data.recovery_codes_warning);
setTwoFactorPanels(!!data.two_factor_enabled);
setPasskeyPanels(!!data.passkeys_enabled, data.passkey_count || 0);
renderRecoveryCodes([]);
loadPasskeys();
setupPasswordVisibilityToggles(document.getElementById('account-settings-content') || document);
} catch (err) {
showAccountSettingsMessage(err.message || 'Failed to load account settings.', 'error');
}
@@ -581,10 +629,8 @@ async function saveAccountUsername(event) {
const username = document.getElementById('username');
const avatar = document.getElementById('avatar');
const avatarPreview = document.getElementById('settings-avatar-preview');
if (username) username.innerText = data.username;
if (avatar && data.username) avatar.innerText = data.username[0].toLocaleUpperCase();
if (avatarPreview && data.username) avatarPreview.innerText = data.username[0].toLocaleUpperCase();
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not update username.', 'error');
}
@@ -681,8 +727,6 @@ async function enableTwoFactor(event) {
async function disableTwoFactor(event) {
event.preventDefault();
if (!confirm('Disable 2FA for your account?')) return;
try {
await apiRequest('/api/2fa/disable', 'POST', {
password: document.getElementById('two-factor-disable-password').value,
@@ -707,8 +751,6 @@ async function disableTwoFactor(event) {
async function regenerateRecoveryCodes(event) {
event.preventDefault();
if (!confirm('Generate new recovery codes? Existing unused codes will stop working.')) return;
try {
const data = await apiRequest('/api/2fa/recovery-codes/regenerate', 'POST', {
password: document.getElementById('recovery-password').value,
@@ -752,3 +794,247 @@ function downloadRecoveryCodes() {
link.remove();
URL.revokeObjectURL(url);
}
function webauthnSupported() {
return window.PublicKeyCredential && navigator.credentials;
}
function webauthnBase64URLToBuffer(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 webauthnBufferToBase64URL(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 prepareCredentialCreationOptions(options) {
const publicKey = options.publicKey || options;
publicKey.challenge = webauthnBase64URLToBuffer(publicKey.challenge);
publicKey.user.id = webauthnBase64URLToBuffer(publicKey.user.id);
if (Array.isArray(publicKey.excludeCredentials)) {
publicKey.excludeCredentials = publicKey.excludeCredentials.map((credential) => ({
...credential,
id: webauthnBase64URLToBuffer(credential.id)
}));
}
return publicKey;
}
function attestationCredentialToJSON(credential) {
const response = credential.response;
return {
id: credential.id,
rawId: webauthnBufferToBase64URL(credential.rawId),
type: credential.type,
response: {
clientDataJSON: webauthnBufferToBase64URL(response.clientDataJSON),
attestationObject: webauthnBufferToBase64URL(response.attestationObject),
transports: typeof response.getTransports === 'function' ? response.getTransports() : []
},
clientExtensionResults: credential.getClientExtensionResults()
};
}
function setPasskeyPanels(enabled, count) {
const badge = document.getElementById('passkey-badge');
const status = document.getElementById('passkey-status');
const unsupported = document.getElementById('passkey-unsupported-message');
const addForm = document.getElementById('passkey-add-form');
if (unsupported) unsupported.style.display = webauthnSupported() ? 'none' : 'block';
if (addForm) {
for (const element of addForm.elements) {
element.disabled = !webauthnSupported();
}
}
if (!badge || !status) return;
if (enabled) {
badge.textContent = 'Enabled';
badge.classList.add('success');
status.textContent = `${count} passkey${count === 1 ? '' : 's'} registered for this account.`;
} else {
badge.textContent = 'Disabled';
badge.classList.remove('success');
status.textContent = 'No passkeys are registered for this account.';
}
}
function renderPasskeys(passkeys) {
currentPasskeys = passkeys || [];
const list = document.getElementById('passkey-list');
if (!list) return;
if (currentPasskeys.length === 0) {
list.innerHTML = '<p style="color: var(--text-muted); margin: 0;">No passkeys registered.</p>';
return;
}
list.innerHTML = currentPasskeys.map((passkey) => {
const created = passkey.created_at ? new Date(passkey.created_at * 1000).toLocaleString() : 'unknown';
const lastUsed = passkey.last_used_at ? new Date(passkey.last_used_at * 1000).toLocaleString() : 'never';
const id = escapeAttr(passkey.id);
return `
<div style="border: 1px solid var(--border); border-radius: 12px; padding: 1rem; display: grid; gap: 0.75rem;">
<div style="display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; flex-wrap: wrap;">
<div>
<strong style="color: var(--text);">${escapeHTML(passkey.name || 'Passkey')}</strong>
<div style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.25rem;">Created: ${escapeHTML(created)}</div>
<div style="color: var(--text-muted); font-size: 0.85rem; margin-top: 0.25rem;">Last used: ${escapeHTML(lastUsed)}</div>
</div>
<button type="button" class="btn btn-secondary danger-btn" style="width: auto; padding: 0.45rem 0.8rem;" onclick="showPasskeyDeleteForm('${id}')">Remove</button>
</div>
<div id="passkey-delete-form-${id}" style="display: none; border-top: 1px solid var(--border); padding-top: 0.75rem;">
<p style="color: var(--text-muted); margin-bottom: 0.75rem;">Confirm with your current password to remove this passkey.</p>
<div class="form-group" style="margin-bottom: 0.75rem;">
<input type="password" id="passkey-delete-password-${id}" placeholder="Current password" autocomplete="current-password">
</div>
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;">
<button type="button" class="btn btn-secondary danger-btn" style="width: auto; padding: 0.45rem 0.8rem;" onclick="confirmDeletePasskey('${id}')">Confirm removal</button>
<button type="button" class="btn btn-secondary" style="width: auto; padding: 0.45rem 0.8rem;" onclick="hidePasskeyDeleteForm('${id}')">Cancel</button>
</div>
</div>
</div>
`;
}).join('');
setupPasswordVisibilityToggles(list);
}
async function loadPasskeys() {
if (!document.getElementById('passkey-list')) return;
try {
const data = await apiRequest('/api/passkeys');
renderPasskeys(data.passkeys || []);
setPasskeyPanels(!!data.passkeys_enabled, data.passkey_count || 0);
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not load passkeys.', 'error');
}
}
async function addPasskey(event) {
event.preventDefault();
if (!webauthnSupported()) {
showAccountSettingsMessage('Passkeys are not supported by this browser.', 'error');
return;
}
const name = document.getElementById('passkey-name').value.trim() || 'Passkey';
const password = document.getElementById('passkey-add-password').value;
try {
const optionsData = await apiRequest('/api/passkeys/register/options', 'POST', { name, password });
const publicKey = prepareCredentialCreationOptions(optionsData.options);
const credential = await navigator.credentials.create({ publicKey });
if (!credential) {
throw new Error('Passkey creation was cancelled.');
}
const data = await apiRequest('/api/passkeys/register/finish', 'POST', {
session_token: optionsData.session_token,
name,
credential: attestationCredentialToJSON(credential)
});
if (data.access_token && data.refresh_token) {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
}
document.getElementById('passkey-add-form').reset();
renderPasskeys(data.passkeys || []);
setPasskeyPanels(!!data.passkeys_enabled, data.passkey_count || 0);
showAccountSettingsMessage('Passkey added. Your session was refreshed.');
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not add passkey.', 'error');
}
}
function showPasskeyDeleteForm(id) {
const form = document.getElementById(`passkey-delete-form-${id}`);
const input = document.getElementById(`passkey-delete-password-${id}`);
if (!form) return;
form.style.display = 'block';
setupPasswordVisibilityToggles(form);
if (input) input.focus();
}
function hidePasskeyDeleteForm(id) {
const form = document.getElementById(`passkey-delete-form-${id}`);
const input = document.getElementById(`passkey-delete-password-${id}`);
if (input) input.value = '';
if (form) form.style.display = 'none';
}
async function confirmDeletePasskey(id) {
if (!id) return;
const input = document.getElementById(`passkey-delete-password-${id}`);
const password = input ? input.value : '';
if (!password) {
showAccountSettingsMessage('Current password required to remove passkey.', 'error');
return;
}
try {
const data = await apiRequest('/api/passkeys', 'DELETE', { id, password });
if (data.access_token && data.refresh_token) {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
}
renderPasskeys(data.passkeys || []);
setPasskeyPanels(!!data.passkeys_enabled, data.passkey_count || 0);
showAccountSettingsMessage('Passkey removed. Your session was refreshed.');
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not remove passkey.', 'error');
}
}
async function deletePasskey(id) {
showPasskeyDeleteForm(id);
}
async function disablePasskeys(event) {
event.preventDefault();
try {
const data = await apiRequest('/api/passkeys/disable', 'POST', {
password: document.getElementById('passkey-disable-password').value
});
if (data.access_token && data.refresh_token) {
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
}
document.getElementById('passkey-disable-form').reset();
renderPasskeys([]);
setPasskeyPanels(false, 0);
showAccountSettingsMessage('Passkeys disabled. Your session was refreshed.');
} catch (err) {
showAccountSettingsMessage(err.message || 'Could not disable passkeys.', 'error');
}
}
function escapeHTML(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeAttr(value) {
return escapeHTML(value).replace(/`/g, '&#96;');
}

View File

@@ -7,16 +7,70 @@ document.addEventListener("DOMContentLoaded", () => {
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);
@@ -26,15 +80,131 @@ document.addEventListener("DOMContentLoaded", () => {
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();
errorBox.style.display = "none";
clearError();
submitButton.disabled = true;
try {