towerops/assets/js/passkey_discoverable.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

122 lines
4.3 KiB
TypeScript

// Discoverable passkey authentication (usernameless login)
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
import type { WebAuthnAuthChallengeData, ErrorResponse, AuthSuccessResponse } from './types/webauthn'
document.addEventListener('DOMContentLoaded', () => {
const discoverableBtn = document.getElementById('passkey-discoverable-btn') as HTMLButtonElement | null
const errorDiv = document.getElementById('passkey-discoverable-error')
if (!discoverableBtn || !errorDiv) return
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 = ''
}
}
const authenticateWithDiscoverablePasskey = async () => {
try {
hideError()
discoverableBtn.disabled = true
discoverableBtn.textContent = 'Authenticating...'
// Fetch challenge from server (no email required)
const challengeResponse = await fetch('/api/webauthn/authentication/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 authentication challenge')
return
}
const challengeData: WebAuthnAuthChallengeData = await challengeResponse.json()
// Convert challenge to ArrayBuffer
const challenge = base64urlToBuffer(challengeData.challenge)
const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
challenge,
rpId: challengeData.rpId,
timeout: challengeData.timeout,
userVerification: challengeData.userVerification as UserVerificationRequirement
// No allowCredentials - let the authenticator show all credentials for this RP ID
}
// Get assertion from authenticator
const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions
}) as PublicKeyCredential
if (!assertion.response || !(assertion.response instanceof AuthenticatorAssertionResponse)) {
throw new Error('Invalid assertion response')
}
const authData = {
assertion: {
id: assertion.id,
rawId: bufferToBase64url(assertion.rawId),
type: assertion.type,
response: {
clientDataJSON: bufferToBase64url(assertion.response.clientDataJSON),
authenticatorData: bufferToBase64url(assertion.response.authenticatorData),
signature: bufferToBase64url(assertion.response.signature),
userHandle: assertion.response.userHandle ?
bufferToBase64url(assertion.response.userHandle) : null
}
}
}
// Send to server
const authResponse = await fetch('/api/webauthn/authenticate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
},
body: JSON.stringify(authData)
})
if (authResponse.ok) {
const result: AuthSuccessResponse = await authResponse.json()
// Redirect to the provided URL
window.location.href = result.redirect
} else {
showError('Authentication failed')
}
} catch (error) {
console.error('Discoverable authentication error:', error)
let errorMessage = 'Failed to authenticate'
if ((error as Error).name === 'NotAllowedError') {
errorMessage = 'Authentication was cancelled or timed out'
} else if ((error as Error).name === 'NotSupportedError') {
errorMessage = 'Your browser or authenticator does not support passkeys'
} else if ((error as Error).message) {
errorMessage = (error as Error).message
}
showError(errorMessage)
} finally {
discoverableBtn.disabled = false
discoverableBtn.innerHTML = '<svg class="h-5 w-5"><use href="#hero-key"></use></svg> Log in with passkey'
}
}
discoverableBtn.addEventListener('click', authenticateWithDiscoverablePasskey)
})