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

144 lines
4.8 KiB
TypeScript

// Passkey authentication for login page
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
import type { WebAuthnAuthChallengeData, ErrorResponse, AuthSuccessResponse } from './types/webauthn'
document.addEventListener('DOMContentLoaded', () => {
const passkeyLoginBtn = document.getElementById('passkey-login-btn') as HTMLButtonElement | null
const emailInput = document.querySelector<HTMLInputElement>('#passkey_login_form input[type="email"]')
const errorDiv = document.getElementById('passkey-login-error')
if (!passkeyLoginBtn || !emailInput || !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 authenticateWithPasskey = async () => {
const email = emailInput.value.trim()
if (!email) {
showError('Please enter your email address')
emailInput.focus()
return
}
try {
hideError()
passkeyLoginBtn.disabled = true
passkeyLoginBtn.textContent = 'Authenticating...'
// Fetch challenge from server
const challengeResponse = await fetch('/api/webauthn/authentication/challenge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken()
},
body: JSON.stringify({ email })
})
if (!challengeResponse.ok) {
const error: ErrorResponse = await challengeResponse.json()
showError(error.error || 'No passkeys found for this email')
return
}
const challengeData: WebAuthnAuthChallengeData = await challengeResponse.json()
// Convert challenge to ArrayBuffer
const challenge = base64urlToBuffer(challengeData.challenge)
const allowCredentials: PublicKeyCredentialDescriptor[] = challengeData.allowCredentials.map(cred => ({
type: cred.type as PublicKeyCredentialType,
id: base64urlToBuffer(cred.id),
transports: cred.transports as AuthenticatorTransport[]
}))
const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
challenge,
rpId: challengeData.rpId,
allowCredentials,
timeout: challengeData.timeout,
userVerification: challengeData.userVerification as UserVerificationRequirement
}
// 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('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).message) {
errorMessage = (error as Error).message
}
showError(errorMessage)
} finally {
passkeyLoginBtn.disabled = false
passkeyLoginBtn.innerHTML = '<svg class="h-5 w-5"><use href="#hero-key"></use></svg> Log in with passkey'
}
}
passkeyLoginBtn.addEventListener('click', authenticateWithPasskey)
// Allow pressing Enter in email field to trigger passkey login
emailInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault()
authenticateWithPasskey()
}
})
})