towerops/assets/js/passkey_settings.ts
Graham McIntire ab6ecbdfdc
Convert JavaScript to TypeScript with proper type definitions
- 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
2026-01-15 13:29:49 -06:00

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()
}
})
})