added passkey support (closes #6)
All checks were successful
test-and-lint / test-and-lint (pull_request) Successful in 2m50s
All checks were successful
test-and-lint / test-and-lint (pull_request) Successful in 2m50s
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHTML(value).replace(/`/g, '`');
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<div class="modal-split" style="align-items: start;">
|
||||
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem;">
|
||||
<h2 style="font-size: 1.25rem; margin-bottom: 0.5rem; color: var(--text);">Profile</h2>
|
||||
<p style="color: var(--text-muted); margin-bottom: 1.5rem;">Change your username. Avatar upload is planned for later.</p>
|
||||
<p style="color: var(--text-muted); margin-bottom: 1.5rem;">Change your username.</p>
|
||||
|
||||
<form id="username-form" onsubmit="saveAccountUsername(event)">
|
||||
<div class="form-group">
|
||||
@@ -22,17 +22,6 @@
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save username</button>
|
||||
</form>
|
||||
|
||||
<div style="margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border);">
|
||||
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Avatar</h3>
|
||||
<div style="display:flex; align-items:center; gap:1rem; color: var(--text-muted);">
|
||||
<div id="settings-avatar-preview" class="avatar">M</div>
|
||||
<div>
|
||||
<div>Avatar upload is not implemented yet.</div>
|
||||
<div style="font-size:0.85rem; margin-top:0.25rem;">This placeholder keeps the settings layout ready for it.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem;">
|
||||
@@ -136,5 +125,48 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem; margin-top: 1.5rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; flex-wrap: wrap;">
|
||||
<div>
|
||||
<h2 style="font-size: 1.25rem; margin-bottom: 0.5rem; color: var(--text);">Passkeys</h2>
|
||||
<p id="passkey-status" style="color: var(--text-muted); margin-bottom: 1rem;">Loading passkey status...</p>
|
||||
</div>
|
||||
<span id="passkey-badge" class="badge">Unknown</span>
|
||||
</div>
|
||||
|
||||
<p style="color: var(--text-muted); margin-bottom: 1rem;">Use device-bound or synced passkeys for phishing-resistant sign-in. Passkeys are protected by your browser, operating system, or security key.</p>
|
||||
|
||||
<div id="passkey-unsupported-message" class="message error" style="display: none; margin-bottom: 1rem;">This browser does not support passkeys.</div>
|
||||
|
||||
<div id="passkey-list" style="display: grid; gap: 0.75rem; margin-bottom: 1.5rem;"></div>
|
||||
|
||||
<div class="modal-split" style="align-items: start;">
|
||||
<div>
|
||||
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Add passkey</h3>
|
||||
<p style="color: var(--text-muted); margin-bottom: 1rem;">Confirm with your current password, then follow your browser's passkey prompt.</p>
|
||||
<form id="passkey-add-form" onsubmit="addPasskey(event)">
|
||||
<div class="form-group">
|
||||
<input type="text" id="passkey-name" placeholder="Passkey name" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input type="password" id="passkey-add-password" placeholder="Current password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem;">Add passkey</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 style="font-size: 1rem; margin-bottom: 0.75rem; color: var(--text);">Disable passkeys</h3>
|
||||
<p style="color: var(--text-muted); margin-bottom: 1rem;">This removes all stored passkeys and revokes active refresh sessions.</p>
|
||||
<form id="passkey-disable-form" onsubmit="disablePasskeys(event)">
|
||||
<div class="form-group">
|
||||
<input type="password" id="passkey-disable-password" placeholder="Current password" required autocomplete="current-password">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-secondary danger-btn">Disable all passkeys</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
</div>
|
||||
|
||||
<button type="submit" id="login-submit" class="btn btn-primary">Sign In</button>
|
||||
<button type="button" id="passkey-login-button" class="btn btn-secondary" style="margin-top: 0.75rem;">Sign in with passkey</button>
|
||||
<p id="passkey-login-hint" class="subtitle" style="margin-top: 0.75rem;">Use a saved passkey from this device, your browser, or a security key. No username is required for passkey sign-in.</p>
|
||||
</form>
|
||||
|
||||
<div id="error" class="message error"></div>
|
||||
|
||||
Reference in New Issue
Block a user