170 lines
5 KiB
JavaScript
170 lines
5 KiB
JavaScript
// Passkey management for settings page
|
|
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const addPasskeyBtn = document.getElementById('add-passkey-btn')
|
|
const modal = document.getElementById('passkey-modal')
|
|
const nameInput = document.getElementById('passkey-name-input')
|
|
const confirmBtn = document.getElementById('confirm-add-passkey')
|
|
const cancelBtn = document.getElementById('cancel-add-passkey')
|
|
const errorDiv = document.getElementById('passkey-error')
|
|
|
|
if (!addPasskeyBtn || !modal) return
|
|
|
|
// Show modal when clicking "Add Passkey"
|
|
addPasskeyBtn.addEventListener('click', () => {
|
|
modal.classList.remove('hidden')
|
|
nameInput.value = ''
|
|
nameInput.focus()
|
|
hideError()
|
|
})
|
|
|
|
// Hide modal when clicking cancel or background
|
|
const hideModal = () => {
|
|
modal.classList.add('hidden')
|
|
nameInput.value = ''
|
|
hideError()
|
|
}
|
|
|
|
cancelBtn.addEventListener('click', hideModal)
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal || e.target.classList.contains('bg-zinc-500')) {
|
|
hideModal()
|
|
}
|
|
})
|
|
|
|
// Handle escape key
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
|
|
hideModal()
|
|
}
|
|
})
|
|
|
|
// Show error in modal
|
|
const showError = (message) => {
|
|
errorDiv.classList.remove('hidden')
|
|
errorDiv.querySelector('p').textContent = message
|
|
}
|
|
|
|
const hideError = () => {
|
|
errorDiv.classList.add('hidden')
|
|
errorDiv.querySelector('p').textContent = ''
|
|
}
|
|
|
|
// Handle passkey registration
|
|
const registerPasskey = async () => {
|
|
const name = nameInput.value.trim()
|
|
|
|
if (!name) {
|
|
showError('Please enter a name for this passkey')
|
|
nameInput.focus()
|
|
return
|
|
}
|
|
|
|
try {
|
|
confirmBtn.disabled = true
|
|
confirmBtn.textContent = 'Registering...'
|
|
|
|
// Fetch challenge from server
|
|
const challengeResponse = await fetch('/api/webauthn/registration/challenge', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': getCsrfToken()
|
|
}
|
|
})
|
|
|
|
if (!challengeResponse.ok) {
|
|
const error = await challengeResponse.json()
|
|
showError(error.error || 'Failed to get registration challenge')
|
|
return
|
|
}
|
|
|
|
const challengeData = await challengeResponse.json()
|
|
|
|
// Convert challenge and user ID to ArrayBuffer
|
|
const challenge = base64urlToBuffer(challengeData.challenge)
|
|
const userId = base64urlToBuffer(challengeData.user.id)
|
|
|
|
const publicKeyCredentialCreationOptions = {
|
|
challenge,
|
|
rp: challengeData.rp,
|
|
user: {
|
|
id: userId,
|
|
name: challengeData.user.name,
|
|
displayName: challengeData.user.displayName
|
|
},
|
|
pubKeyCredParams: challengeData.pubKeyCredParams,
|
|
authenticatorSelection: challengeData.authenticatorSelection,
|
|
timeout: challengeData.timeout,
|
|
attestation: challengeData.attestation
|
|
}
|
|
|
|
// Hide modal before showing authenticator prompt
|
|
modal.classList.add('hidden')
|
|
|
|
// Create credential
|
|
const credential = await navigator.credentials.create({
|
|
publicKey: publicKeyCredentialCreationOptions
|
|
})
|
|
|
|
const registrationData = {
|
|
name: name,
|
|
attestation: {
|
|
id: credential.id,
|
|
rawId: bufferToBase64url(credential.rawId),
|
|
type: credential.type,
|
|
response: {
|
|
clientDataJSON: bufferToBase64url(credential.response.clientDataJSON),
|
|
attestationObject: bufferToBase64url(credential.response.attestationObject)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Send to server
|
|
const registerResponse = await fetch('/api/webauthn/register', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': getCsrfToken()
|
|
},
|
|
body: JSON.stringify(registrationData)
|
|
})
|
|
|
|
if (registerResponse.ok) {
|
|
// Reload page to show new passkey
|
|
window.location.reload()
|
|
} else {
|
|
const error = await registerResponse.json()
|
|
modal.classList.remove('hidden')
|
|
showError(error.error || 'Failed to register passkey')
|
|
}
|
|
} catch (error) {
|
|
console.error('Registration error:', error)
|
|
|
|
let errorMessage = 'Failed to register passkey'
|
|
if (error.name === 'NotAllowedError') {
|
|
errorMessage = 'Registration was cancelled or timed out'
|
|
} else if (error.message) {
|
|
errorMessage = error.message
|
|
}
|
|
|
|
modal.classList.remove('hidden')
|
|
showError(errorMessage)
|
|
} finally {
|
|
confirmBtn.disabled = false
|
|
confirmBtn.textContent = 'Continue'
|
|
}
|
|
}
|
|
|
|
// Register on button click
|
|
confirmBtn.addEventListener('click', registerPasskey)
|
|
|
|
// Register on Enter key
|
|
nameInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
registerPasskey()
|
|
}
|
|
})
|
|
})
|