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

@@ -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;');
}