- Add comprehensive type definitions for vendor libraries (Chart.js, topbar) - Add TypeScript types for LiveView hooks and WebAuthn interfaces - Convert all JS files to TypeScript with proper typing: - app.js → app.ts - webauthn.js → webauthn.ts - webauthn_utils.js → webauthn_utils.ts - passkey_settings.js → passkey_settings.ts - passkey_login.js → passkey_login.ts - passkey_discoverable.js → passkey_discoverable.ts - Update tsconfig.json with modern ES2022 and strict type checking - Update esbuild config to use app.ts as entry point - All assets compile successfully with esbuild's native TypeScript support - No node_modules required, following Phoenix/Elixir conventions
181 lines
5.7 KiB
TypeScript
181 lines
5.7 KiB
TypeScript
// Passkey management for settings page
|
|
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
|
|
import type { WebAuthnChallengeData, ErrorResponse } from './types/webauthn'
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const addPasskeyBtn = document.getElementById('add-passkey-btn')
|
|
const modal = document.getElementById('passkey-modal')
|
|
const nameInput = document.getElementById('passkey-name-input') as HTMLInputElement | null
|
|
const confirmBtn = document.getElementById('confirm-add-passkey') as HTMLButtonElement | null
|
|
const cancelBtn = document.getElementById('cancel-add-passkey')
|
|
const errorDiv = document.getElementById('passkey-error')
|
|
|
|
if (!addPasskeyBtn || !modal || !nameInput || !confirmBtn || !cancelBtn || !errorDiv) 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 as HTMLElement).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: string) => {
|
|
errorDiv.classList.remove('hidden')
|
|
const errorText = errorDiv.querySelector('p')
|
|
if (errorText) {
|
|
errorText.textContent = message
|
|
}
|
|
}
|
|
|
|
const hideError = () => {
|
|
errorDiv.classList.add('hidden')
|
|
const errorText = errorDiv.querySelector('p')
|
|
if (errorText) {
|
|
errorText.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: ErrorResponse = await challengeResponse.json()
|
|
showError(error.error || 'Failed to get registration challenge')
|
|
return
|
|
}
|
|
|
|
const challengeData: WebAuthnChallengeData = await challengeResponse.json()
|
|
|
|
// Convert challenge and user ID to ArrayBuffer
|
|
const challenge = base64urlToBuffer(challengeData.challenge)
|
|
const userId = base64urlToBuffer(challengeData.user.id)
|
|
|
|
const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = {
|
|
challenge,
|
|
rp: challengeData.rp,
|
|
user: {
|
|
id: userId,
|
|
name: challengeData.user.name,
|
|
displayName: challengeData.user.displayName
|
|
},
|
|
pubKeyCredParams: challengeData.pubKeyCredParams as PublicKeyCredentialParameters[],
|
|
authenticatorSelection: challengeData.authenticatorSelection,
|
|
timeout: challengeData.timeout,
|
|
attestation: (challengeData.attestation as AttestationConveyancePreference) || 'none'
|
|
}
|
|
|
|
// Hide modal before showing authenticator prompt
|
|
modal.classList.add('hidden')
|
|
|
|
// Create credential
|
|
const credential = await navigator.credentials.create({
|
|
publicKey: publicKeyCredentialCreationOptions
|
|
}) as PublicKeyCredential
|
|
|
|
if (!credential.response || !(credential.response instanceof AuthenticatorAttestationResponse)) {
|
|
throw new Error('Invalid credential response')
|
|
}
|
|
|
|
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: ErrorResponse = 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 as Error).name === 'NotAllowedError') {
|
|
errorMessage = 'Registration was cancelled or timed out'
|
|
} else if ((error as Error).message) {
|
|
errorMessage = (error as 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()
|
|
}
|
|
})
|
|
})
|