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
This commit is contained in:
Graham McIntire 2026-01-15 13:29:49 -06:00
parent 2bacf6337c
commit ab6ecbdfdc
No known key found for this signature in database
11 changed files with 468 additions and 178 deletions

View file

@ -20,18 +20,20 @@
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons. // Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
import "phoenix_html" import "phoenix_html"
// Establish Phoenix Socket and LiveView configuration. // Establish Phoenix Socket and LiveView configuration.
import {Socket} from "phoenix" import { Socket } from "phoenix"
import {LiveSocket} from "phoenix_live_view" import { LiveSocket } from "phoenix_live_view"
import {hooks as colocatedHooks} from "phoenix-colocated/towerops" import { hooks as colocatedHooks } from "phoenix-colocated/towerops"
import topbar from "../vendor/topbar" import topbar from "../vendor/topbar"
import Chart from "../vendor/chart" import Chart from "../vendor/chart"
import {WebAuthnRegister, WebAuthnLogin} from "./webauthn" import type { ChartData, ChartDataset, ChartDataPoint } from "../vendor/chart"
import { WebAuthnRegister, WebAuthnLogin } from "./webauthn"
import "./passkey_settings" import "./passkey_settings"
import "./passkey_login" import "./passkey_login"
import "./passkey_discoverable" import "./passkey_discoverable"
import type { SensorChartHook } from "./types/liveview"
// Generic Sensor Chart Hook (for CPU, Memory, Storage, etc.) // Generic Sensor Chart Hook (for CPU, Memory, Storage, etc.)
const SensorChart = { const SensorChart: SensorChartHook = {
mounted() { mounted() {
this.createChart() this.createChart()
}, },
@ -51,12 +53,21 @@ const SensorChart = {
}, },
createChart() { createChart() {
const data = JSON.parse(this.el.dataset.chart) if (!this.el) return
const dataAttr = this.el.dataset.chart
if (!dataAttr) return
const data: ChartData = JSON.parse(dataAttr)
const canvas = this.el.querySelector('canvas') const canvas = this.el.querySelector('canvas')
if (!canvas) return
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
if (!ctx) return
const unit = this.el.dataset.unit || '%' const unit = this.el.dataset.unit || '%'
const autoScale = this.el.dataset.autoScale === 'true' || this.el.dataset.autoScale === true const autoScale = this.el.dataset.autoScale === 'true' || this.el.dataset.autoScale === 'true'
const showZeroLine = this.el.dataset.showZeroLine === 'true' || this.el.dataset.showZeroLine === true const showZeroLine = this.el.dataset.showZeroLine === 'true' || this.el.dataset.showZeroLine === 'true'
const chartType = unit === 'bps' ? 'bar' : 'line' const chartType = unit === 'bps' ? 'bar' : 'line'
// Generate colors for each dataset // Generate colors for each dataset
@ -71,8 +82,8 @@ const SensorChart = {
'rgb(249, 115, 22)', // orange 'rgb(249, 115, 22)', // orange
] ]
const datasets = data.datasets.map((dataset, index) => { const datasets: ChartDataset[] = data.datasets.map((dataset, index) => {
const baseConfig = { const baseConfig: ChartDataset = {
...dataset, ...dataset,
borderColor: colors[index % colors.length], borderColor: colors[index % colors.length],
backgroundColor: colors[index % colors.length].replace('rgb', 'rgba').replace(')', ', 0.5)'), backgroundColor: colors[index % colors.length].replace('rgb', 'rgba').replace(')', ', 0.5)'),
@ -90,7 +101,7 @@ const SensorChart = {
}) })
// Format bits per second into human-readable units // Format bits per second into human-readable units
const formatBps = (bps) => { const formatBps = (bps: number): string => {
// Use absolute value for display (inbound is negative, but we show it as positive) // Use absolute value for display (inbound is negative, but we show it as positive)
const absBps = Math.abs(bps) const absBps = Math.abs(bps)
if (absBps >= 1_000_000_000) { if (absBps >= 1_000_000_000) {
@ -105,14 +116,14 @@ const SensorChart = {
} }
// For traffic graphs, calculate symmetric scale to keep zero in the middle // For traffic graphs, calculate symmetric scale to keep zero in the middle
let yAxisConfig = {} let yAxisConfig: any = {}
if (autoScale) { if (autoScale) {
if (showZeroLine && unit === 'bps') { if (showZeroLine && unit === 'bps') {
// Find max absolute value to make symmetric scale // Find max absolute value to make symmetric scale
let maxAbsValue = 0 let maxAbsValue = 0
datasets.forEach(dataset => { datasets.forEach(dataset => {
if (dataset.data && dataset.data.length > 0) { if (dataset.data && dataset.data.length > 0) {
dataset.data.forEach(point => { dataset.data.forEach((point: ChartDataPoint) => {
if (point && typeof point.y === 'number') { if (point && typeof point.y === 'number') {
maxAbsValue = Math.max(maxAbsValue, Math.abs(point.y)) maxAbsValue = Math.max(maxAbsValue, Math.abs(point.y))
} }
@ -126,19 +137,19 @@ const SensorChart = {
min: -maxAbsValue, min: -maxAbsValue,
max: maxAbsValue, max: maxAbsValue,
ticks: { ticks: {
callback: function(value) { callback: function(value: number) {
return formatBps(value) return formatBps(value)
} }
}, },
grid: { grid: {
color: function(context) { color: function(context: any) {
// Make the zero line more prominent // Make the zero line more prominent
if (context.tick.value === 0) { if (context.tick.value === 0) {
return 'rgba(0, 0, 0, 0.3)' return 'rgba(0, 0, 0, 0.3)'
} }
return 'rgba(0, 0, 0, 0.05)' return 'rgba(0, 0, 0, 0.05)'
}, },
lineWidth: function(context) { lineWidth: function(context: any) {
// Make the zero line thicker // Make the zero line thicker
if (context.tick.value === 0) { if (context.tick.value === 0) {
return 2 return 2
@ -151,7 +162,7 @@ const SensorChart = {
yAxisConfig = { yAxisConfig = {
beginAtZero: showZeroLine, beginAtZero: showZeroLine,
ticks: { ticks: {
callback: function(value) { callback: function(value: number) {
if (unit === 'bps') { if (unit === 'bps') {
return formatBps(value) return formatBps(value)
} }
@ -159,14 +170,14 @@ const SensorChart = {
} }
}, },
grid: { grid: {
color: function(context) { color: function(context: any) {
// Make the zero line more prominent // Make the zero line more prominent
if (showZeroLine && context.tick.value === 0) { if (showZeroLine && context.tick.value === 0) {
return 'rgba(0, 0, 0, 0.3)' return 'rgba(0, 0, 0, 0.3)'
} }
return 'rgba(0, 0, 0, 0.05)' return 'rgba(0, 0, 0, 0.05)'
}, },
lineWidth: function(context) { lineWidth: function(context: any) {
// Make the zero line thicker // Make the zero line thicker
if (showZeroLine && context.tick.value === 0) { if (showZeroLine && context.tick.value === 0) {
return 2 return 2
@ -181,7 +192,7 @@ const SensorChart = {
min: 0, min: 0,
max: 100, max: 100,
ticks: { ticks: {
callback: function(value) { callback: function(value: number) {
if (unit === 'bps') { if (unit === 'bps') {
return formatBps(value) return formatBps(value)
} }
@ -221,7 +232,7 @@ const SensorChart = {
}, },
tooltip: { tooltip: {
callbacks: { callbacks: {
title: function(context) { title: function(context: any) {
const timestamp = context[0].parsed.x const timestamp = context[0].parsed.x
const date = new Date(timestamp) const date = new Date(timestamp)
return date.toLocaleString('en-US', { return date.toLocaleString('en-US', {
@ -232,7 +243,7 @@ const SensorChart = {
hour12: false hour12: false
}) })
}, },
label: function(context) { label: function(context: any) {
if (unit === 'bps') { if (unit === 'bps') {
return context.dataset.label + ': ' + formatBps(context.parsed.y) return context.dataset.label + ': ' + formatBps(context.parsed.y)
} }
@ -247,7 +258,7 @@ const SensorChart = {
min: twentyFourHoursAgo, min: twentyFourHoursAgo,
max: now, max: now,
ticks: { ticks: {
callback: function(value) { callback: function(value: number) {
const date = new Date(value) const date = new Date(value)
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false }) return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
}, },
@ -264,27 +275,32 @@ const SensorChart = {
} }
} }
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const csrfToken = document.querySelector<HTMLMetaElement>("meta[name='csrf-token']")?.getAttribute("content")
if (!csrfToken) {
throw new Error('CSRF token meta tag not found')
}
const liveSocket = new LiveSocket("/live", Socket, { const liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500, longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken}, params: { _csrf_token: csrfToken },
hooks: {...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin}, hooks: { ...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin },
}) })
// Show progress bar on live navigation and form submits // Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" })
window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) window.addEventListener("phx:page-loading-stop", _info => topbar.hide())
// Handle clipboard copy events // Handle clipboard copy events
window.addEventListener("phx:copy", (event) => { window.addEventListener("phx:copy", (event) => {
const el = event.target const el = event.target as HTMLElement
let text = "" let text = ""
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") { if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
text = el.value text = (el as HTMLInputElement | HTMLTextAreaElement).value
} else { } else {
text = el.innerText || el.textContent text = el.innerText || el.textContent || ""
} }
navigator.clipboard.writeText(text).then(() => { navigator.clipboard.writeText(text).then(() => {
@ -302,6 +318,13 @@ liveSocket.connect()
// >> liveSocket.enableDebug() // >> liveSocket.enableDebug()
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session // >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
// >> liveSocket.disableLatencySim() // >> liveSocket.disableLatencySim()
declare global {
interface Window {
liveSocket: LiveSocket
liveReloader?: any
}
}
window.liveSocket = liveSocket window.liveSocket = liveSocket
// The lines below enable quality of life phoenix_live_reload // The lines below enable quality of life phoenix_live_reload
@ -311,7 +334,7 @@ window.liveSocket = liveSocket
// 2. click on elements to jump to their definitions in your code editor // 2. click on elements to jump to their definitions in your code editor
// //
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
window.addEventListener("phx:live_reload:attached", ({detail: reloader}) => { window.addEventListener("phx:live_reload:attached", ({ detail: reloader }: any) => {
// Enable server log streaming to client. // Enable server log streaming to client.
// Disable with reloader.disableServerLogs() // Disable with reloader.disableServerLogs()
reloader.enableServerLogs() reloader.enableServerLogs()
@ -320,15 +343,15 @@ if (process.env.NODE_ENV === "development") {
// //
// * click with "c" key pressed to open at caller location // * click with "c" key pressed to open at caller location
// * click with "d" key pressed to open at function component definition location // * click with "d" key pressed to open at function component definition location
let keyDown let keyDown: string | null = null
window.addEventListener("keydown", e => keyDown = e.key) window.addEventListener("keydown", e => keyDown = e.key)
window.addEventListener("keyup", _e => keyDown = null) window.addEventListener("keyup", _e => keyDown = null)
window.addEventListener("click", e => { window.addEventListener("click", e => {
if(keyDown === "c"){ if (keyDown === "c") {
e.preventDefault() e.preventDefault()
e.stopImmediatePropagation() e.stopImmediatePropagation()
reloader.openEditorAtCaller(e.target) reloader.openEditorAtCaller(e.target)
} else if(keyDown === "d"){ } else if (keyDown === "d") {
e.preventDefault() e.preventDefault()
e.stopImmediatePropagation() e.stopImmediatePropagation()
reloader.openEditorAtDef(e.target) reloader.openEditorAtDef(e.target)
@ -338,4 +361,3 @@ if (process.env.NODE_ENV === "development") {
window.liveReloader = reloader window.liveReloader = reloader
}) })
} }

View file

@ -1,20 +1,27 @@
// Discoverable passkey authentication (usernameless login) // Discoverable passkey authentication (usernameless login)
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils' import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
import type { WebAuthnAuthChallengeData, ErrorResponse, AuthSuccessResponse } from './types/webauthn'
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const discoverableBtn = document.getElementById('passkey-discoverable-btn') const discoverableBtn = document.getElementById('passkey-discoverable-btn') as HTMLButtonElement | null
const errorDiv = document.getElementById('passkey-discoverable-error') const errorDiv = document.getElementById('passkey-discoverable-error')
if (!discoverableBtn || !errorDiv) return if (!discoverableBtn || !errorDiv) return
const showError = (message) => { const showError = (message: string) => {
errorDiv.classList.remove('hidden') errorDiv.classList.remove('hidden')
errorDiv.querySelector('p').textContent = message const errorText = errorDiv.querySelector('p')
if (errorText) {
errorText.textContent = message
}
} }
const hideError = () => { const hideError = () => {
errorDiv.classList.add('hidden') errorDiv.classList.add('hidden')
errorDiv.querySelector('p').textContent = '' const errorText = errorDiv.querySelector('p')
if (errorText) {
errorText.textContent = ''
}
} }
const authenticateWithDiscoverablePasskey = async () => { const authenticateWithDiscoverablePasskey = async () => {
@ -33,28 +40,32 @@ document.addEventListener('DOMContentLoaded', () => {
}) })
if (!challengeResponse.ok) { if (!challengeResponse.ok) {
const error = await challengeResponse.json() const error: ErrorResponse = await challengeResponse.json()
showError(error.error || 'Failed to get authentication challenge') showError(error.error || 'Failed to get authentication challenge')
return return
} }
const challengeData = await challengeResponse.json() const challengeData: WebAuthnAuthChallengeData = await challengeResponse.json()
// Convert challenge to ArrayBuffer // Convert challenge to ArrayBuffer
const challenge = base64urlToBuffer(challengeData.challenge) const challenge = base64urlToBuffer(challengeData.challenge)
const publicKeyCredentialRequestOptions = { const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
challenge, challenge,
rpId: challengeData.rpId, rpId: challengeData.rpId,
timeout: challengeData.timeout, timeout: challengeData.timeout,
userVerification: challengeData.userVerification userVerification: challengeData.userVerification as UserVerificationRequirement
// No allowCredentials - let the authenticator show all credentials for this RP ID // No allowCredentials - let the authenticator show all credentials for this RP ID
} }
// Get assertion from authenticator // Get assertion from authenticator
const assertion = await navigator.credentials.get({ const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions publicKey: publicKeyCredentialRequestOptions
}) }) as PublicKeyCredential
if (!assertion.response || !(assertion.response instanceof AuthenticatorAssertionResponse)) {
throw new Error('Invalid assertion response')
}
const authData = { const authData = {
assertion: { assertion: {
@ -82,7 +93,7 @@ document.addEventListener('DOMContentLoaded', () => {
}) })
if (authResponse.ok) { if (authResponse.ok) {
const result = await authResponse.json() const result: AuthSuccessResponse = await authResponse.json()
// Redirect to the provided URL // Redirect to the provided URL
window.location.href = result.redirect window.location.href = result.redirect
} else { } else {
@ -92,12 +103,12 @@ document.addEventListener('DOMContentLoaded', () => {
console.error('Discoverable authentication error:', error) console.error('Discoverable authentication error:', error)
let errorMessage = 'Failed to authenticate' let errorMessage = 'Failed to authenticate'
if (error.name === 'NotAllowedError') { if ((error as Error).name === 'NotAllowedError') {
errorMessage = 'Authentication was cancelled or timed out' errorMessage = 'Authentication was cancelled or timed out'
} else if (error.name === 'NotSupportedError') { } else if ((error as Error).name === 'NotSupportedError') {
errorMessage = 'Your browser or authenticator does not support passkeys' errorMessage = 'Your browser or authenticator does not support passkeys'
} else if (error.message) { } else if ((error as Error).message) {
errorMessage = error.message errorMessage = (error as Error).message
} }
showError(errorMessage) showError(errorMessage)

View file

@ -1,21 +1,28 @@
// Passkey authentication for login page // Passkey authentication for login page
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils' import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
import type { WebAuthnAuthChallengeData, ErrorResponse, AuthSuccessResponse } from './types/webauthn'
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const passkeyLoginBtn = document.getElementById('passkey-login-btn') const passkeyLoginBtn = document.getElementById('passkey-login-btn') as HTMLButtonElement | null
const emailInput = document.querySelector('#passkey_login_form input[type="email"]') const emailInput = document.querySelector<HTMLInputElement>('#passkey_login_form input[type="email"]')
const errorDiv = document.getElementById('passkey-login-error') const errorDiv = document.getElementById('passkey-login-error')
if (!passkeyLoginBtn || !emailInput || !errorDiv) return if (!passkeyLoginBtn || !emailInput || !errorDiv) return
const showError = (message) => { const showError = (message: string) => {
errorDiv.classList.remove('hidden') errorDiv.classList.remove('hidden')
errorDiv.querySelector('p').textContent = message const errorText = errorDiv.querySelector('p')
if (errorText) {
errorText.textContent = message
}
} }
const hideError = () => { const hideError = () => {
errorDiv.classList.add('hidden') errorDiv.classList.add('hidden')
errorDiv.querySelector('p').textContent = '' const errorText = errorDiv.querySelector('p')
if (errorText) {
errorText.textContent = ''
}
} }
const authenticateWithPasskey = async () => { const authenticateWithPasskey = async () => {
@ -43,34 +50,38 @@ document.addEventListener('DOMContentLoaded', () => {
}) })
if (!challengeResponse.ok) { if (!challengeResponse.ok) {
const error = await challengeResponse.json() const error: ErrorResponse = await challengeResponse.json()
showError(error.error || 'No passkeys found for this email') showError(error.error || 'No passkeys found for this email')
return return
} }
const challengeData = await challengeResponse.json() const challengeData: WebAuthnAuthChallengeData = await challengeResponse.json()
// Convert challenge to ArrayBuffer // Convert challenge to ArrayBuffer
const challenge = base64urlToBuffer(challengeData.challenge) const challenge = base64urlToBuffer(challengeData.challenge)
const allowCredentials = challengeData.allowCredentials.map(cred => ({ const allowCredentials: PublicKeyCredentialDescriptor[] = challengeData.allowCredentials.map(cred => ({
type: cred.type, type: cred.type as PublicKeyCredentialType,
id: base64urlToBuffer(cred.id), id: base64urlToBuffer(cred.id),
transports: cred.transports transports: cred.transports as AuthenticatorTransport[]
})) }))
const publicKeyCredentialRequestOptions = { const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
challenge, challenge,
rpId: challengeData.rpId, rpId: challengeData.rpId,
allowCredentials, allowCredentials,
timeout: challengeData.timeout, timeout: challengeData.timeout,
userVerification: challengeData.userVerification userVerification: challengeData.userVerification as UserVerificationRequirement
} }
// Get assertion from authenticator // Get assertion from authenticator
const assertion = await navigator.credentials.get({ const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions publicKey: publicKeyCredentialRequestOptions
}) }) as PublicKeyCredential
if (!assertion.response || !(assertion.response instanceof AuthenticatorAssertionResponse)) {
throw new Error('Invalid assertion response')
}
const authData = { const authData = {
assertion: { assertion: {
@ -98,7 +109,7 @@ document.addEventListener('DOMContentLoaded', () => {
}) })
if (authResponse.ok) { if (authResponse.ok) {
const result = await authResponse.json() const result: AuthSuccessResponse = await authResponse.json()
// Redirect to the provided URL // Redirect to the provided URL
window.location.href = result.redirect window.location.href = result.redirect
} else { } else {
@ -108,10 +119,10 @@ document.addEventListener('DOMContentLoaded', () => {
console.error('Authentication error:', error) console.error('Authentication error:', error)
let errorMessage = 'Failed to authenticate' let errorMessage = 'Failed to authenticate'
if (error.name === 'NotAllowedError') { if ((error as Error).name === 'NotAllowedError') {
errorMessage = 'Authentication was cancelled or timed out' errorMessage = 'Authentication was cancelled or timed out'
} else if (error.message) { } else if ((error as Error).message) {
errorMessage = error.message errorMessage = (error as Error).message
} }
showError(errorMessage) showError(errorMessage)

View file

@ -1,15 +1,16 @@
// Passkey management for settings page // Passkey management for settings page
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils' import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
import type { WebAuthnChallengeData, ErrorResponse } from './types/webauthn'
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const addPasskeyBtn = document.getElementById('add-passkey-btn') const addPasskeyBtn = document.getElementById('add-passkey-btn')
const modal = document.getElementById('passkey-modal') const modal = document.getElementById('passkey-modal')
const nameInput = document.getElementById('passkey-name-input') const nameInput = document.getElementById('passkey-name-input') as HTMLInputElement | null
const confirmBtn = document.getElementById('confirm-add-passkey') const confirmBtn = document.getElementById('confirm-add-passkey') as HTMLButtonElement | null
const cancelBtn = document.getElementById('cancel-add-passkey') const cancelBtn = document.getElementById('cancel-add-passkey')
const errorDiv = document.getElementById('passkey-error') const errorDiv = document.getElementById('passkey-error')
if (!addPasskeyBtn || !modal) return if (!addPasskeyBtn || !modal || !nameInput || !confirmBtn || !cancelBtn || !errorDiv) return
// Show modal when clicking "Add Passkey" // Show modal when clicking "Add Passkey"
addPasskeyBtn.addEventListener('click', () => { addPasskeyBtn.addEventListener('click', () => {
@ -28,7 +29,7 @@ document.addEventListener('DOMContentLoaded', () => {
cancelBtn.addEventListener('click', hideModal) cancelBtn.addEventListener('click', hideModal)
modal.addEventListener('click', (e) => { modal.addEventListener('click', (e) => {
if (e.target === modal || e.target.classList.contains('bg-zinc-500')) { if (e.target === modal || (e.target as HTMLElement).classList.contains('bg-zinc-500')) {
hideModal() hideModal()
} }
}) })
@ -41,14 +42,20 @@ document.addEventListener('DOMContentLoaded', () => {
}) })
// Show error in modal // Show error in modal
const showError = (message) => { const showError = (message: string) => {
errorDiv.classList.remove('hidden') errorDiv.classList.remove('hidden')
errorDiv.querySelector('p').textContent = message const errorText = errorDiv.querySelector('p')
if (errorText) {
errorText.textContent = message
}
} }
const hideError = () => { const hideError = () => {
errorDiv.classList.add('hidden') errorDiv.classList.add('hidden')
errorDiv.querySelector('p').textContent = '' const errorText = errorDiv.querySelector('p')
if (errorText) {
errorText.textContent = ''
}
} }
// Handle passkey registration // Handle passkey registration
@ -75,18 +82,18 @@ document.addEventListener('DOMContentLoaded', () => {
}) })
if (!challengeResponse.ok) { if (!challengeResponse.ok) {
const error = await challengeResponse.json() const error: ErrorResponse = await challengeResponse.json()
showError(error.error || 'Failed to get registration challenge') showError(error.error || 'Failed to get registration challenge')
return return
} }
const challengeData = await challengeResponse.json() const challengeData: WebAuthnChallengeData = await challengeResponse.json()
// Convert challenge and user ID to ArrayBuffer // Convert challenge and user ID to ArrayBuffer
const challenge = base64urlToBuffer(challengeData.challenge) const challenge = base64urlToBuffer(challengeData.challenge)
const userId = base64urlToBuffer(challengeData.user.id) const userId = base64urlToBuffer(challengeData.user.id)
const publicKeyCredentialCreationOptions = { const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = {
challenge, challenge,
rp: challengeData.rp, rp: challengeData.rp,
user: { user: {
@ -94,10 +101,10 @@ document.addEventListener('DOMContentLoaded', () => {
name: challengeData.user.name, name: challengeData.user.name,
displayName: challengeData.user.displayName displayName: challengeData.user.displayName
}, },
pubKeyCredParams: challengeData.pubKeyCredParams, pubKeyCredParams: challengeData.pubKeyCredParams as PublicKeyCredentialParameters[],
authenticatorSelection: challengeData.authenticatorSelection, authenticatorSelection: challengeData.authenticatorSelection,
timeout: challengeData.timeout, timeout: challengeData.timeout,
attestation: challengeData.attestation attestation: (challengeData.attestation as AttestationConveyancePreference) || 'none'
} }
// Hide modal before showing authenticator prompt // Hide modal before showing authenticator prompt
@ -106,7 +113,11 @@ document.addEventListener('DOMContentLoaded', () => {
// Create credential // Create credential
const credential = await navigator.credentials.create({ const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions publicKey: publicKeyCredentialCreationOptions
}) }) as PublicKeyCredential
if (!credential.response || !(credential.response instanceof AuthenticatorAttestationResponse)) {
throw new Error('Invalid credential response')
}
const registrationData = { const registrationData = {
name: name, name: name,
@ -135,7 +146,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Reload page to show new passkey // Reload page to show new passkey
window.location.reload() window.location.reload()
} else { } else {
const error = await registerResponse.json() const error: ErrorResponse = await registerResponse.json()
modal.classList.remove('hidden') modal.classList.remove('hidden')
showError(error.error || 'Failed to register passkey') showError(error.error || 'Failed to register passkey')
} }
@ -143,10 +154,10 @@ document.addEventListener('DOMContentLoaded', () => {
console.error('Registration error:', error) console.error('Registration error:', error)
let errorMessage = 'Failed to register passkey' let errorMessage = 'Failed to register passkey'
if (error.name === 'NotAllowedError') { if ((error as Error).name === 'NotAllowedError') {
errorMessage = 'Registration was cancelled or timed out' errorMessage = 'Registration was cancelled or timed out'
} else if (error.message) { } else if ((error as Error).message) {
errorMessage = error.message errorMessage = (error as Error).message
} }
modal.classList.remove('hidden') modal.classList.remove('hidden')

17
assets/js/types/liveview.d.ts vendored Normal file
View file

@ -0,0 +1,17 @@
// Type definitions for Phoenix LiveView hooks
export interface LiveViewHook {
el?: HTMLElement;
mounted?(): void;
updated?(): void;
destroyed?(): void;
disconnected?(): void;
reconnected?(): void;
handleEvent?(event: string, callback: (payload: any) => void | Promise<void>): void;
pushEvent?(event: string, payload: any): void;
}
export interface SensorChartHook extends LiveViewHook {
chart?: any;
createChart(): void;
}

136
assets/js/types/vendor.d.ts vendored Normal file
View file

@ -0,0 +1,136 @@
// Type definitions for vendor libraries
declare module "../vendor/topbar" {
interface Topbar {
config(options: { barColors: { [key: number]: string }; shadowColor: string }): void;
show(delay?: number): void;
hide(): void;
}
const topbar: Topbar;
export default topbar;
}
declare module "../vendor/chart" {
export interface ChartDataPoint {
x: number;
y: number | null;
}
export interface ChartDataset {
label: string;
data: ChartDataPoint[];
borderColor?: string;
backgroundColor?: string;
borderWidth?: number;
tension?: number;
pointRadius?: number;
pointHoverRadius?: number;
}
export interface ChartData {
datasets: ChartDataset[];
}
export interface ChartTickContext {
tick: { value: number };
}
export interface ChartAxisConfig {
type?: string;
min?: number;
max?: number;
beginAtZero?: boolean;
ticks?: {
callback?: (value: number, index?: number) => string;
maxTicksLimit?: number;
};
grid?: {
display?: boolean;
color?: string | ((context: ChartTickContext) => string);
lineWidth?: number | ((context: ChartTickContext) => number);
};
}
export interface ChartTooltipContext {
parsed: { x: number; y: number };
dataset: ChartDataset;
}
export interface ChartOptions {
responsive?: boolean;
maintainAspectRatio?: boolean;
barPercentage?: number;
categoryPercentage?: number;
interaction?: {
mode?: string;
intersect?: boolean;
};
plugins?: {
legend?: {
position?: string;
labels?: {
usePointStyle?: boolean;
padding?: number;
};
};
tooltip?: {
callbacks?: {
title?: (context: ChartTooltipContext[]) => string;
label?: (context: ChartTooltipContext) => string;
};
};
};
scales?: {
x?: ChartAxisConfig;
y?: ChartAxisConfig;
};
}
export interface ChartConfig {
type: string;
data: ChartData;
options: ChartOptions;
}
export default class Chart {
constructor(context: CanvasRenderingContext2D, config: ChartConfig);
destroy(): void;
}
}
declare module "phoenix" {
export class Socket {
constructor(endpoint: string, options?: any);
connect(): void;
enableDebug(): void;
enableLatencySim(ms: number): void;
disableLatencySim(): void;
}
}
declare module "phoenix_live_view" {
import { Socket } from "phoenix";
export interface LiveSocket {
connect(): void;
enableDebug(): void;
enableLatencySim(ms: number): void;
disableLatencySim(): void;
}
export class LiveSocket {
constructor(
endpoint: string,
Socket: typeof Socket,
options: {
longPollFallbackMs?: number;
params?: { _csrf_token: string };
hooks?: Record<string, any>;
}
);
}
}
declare module "phoenix-colocated/towerops" {
export const hooks: Record<string, any>;
}

73
assets/js/types/webauthn.d.ts vendored Normal file
View file

@ -0,0 +1,73 @@
// Type definitions for WebAuthn/Passkey authentication
export interface WebAuthnChallengeData {
challenge: string;
rp: {
name: string;
id: string;
};
user: {
id: string;
name: string;
displayName: string;
};
pubKeyCredParams: Array<{
type: string;
alg: number;
}>;
authenticatorSelection?: {
authenticatorAttachment?: string;
requireResidentKey?: boolean;
residentKey?: string;
userVerification?: string;
};
timeout: number;
attestation?: string;
}
export interface WebAuthnAuthChallengeData {
challenge: string;
rpId: string;
allowCredentials: Array<{
type: string;
id: string;
transports?: string[];
}>;
timeout: number;
userVerification: string;
}
export interface WebAuthnRegistrationResult {
name: string;
attestation: {
id: string;
rawId: string;
type: string;
response: {
clientDataJSON: string;
attestationObject: string;
};
};
}
export interface WebAuthnAuthenticationResult {
assertion: {
id: string;
rawId: string;
type: string;
response: {
clientDataJSON: string;
authenticatorData: string;
signature: string;
userHandle: string | null;
};
};
}
export interface ErrorResponse {
error?: string;
}
export interface AuthSuccessResponse {
redirect: string;
}

View file

@ -1,14 +1,22 @@
// WebAuthn utility functions for passkey authentication // WebAuthn utility functions for passkey authentication
import { base64urlToBuffer, bufferToBase64url, getCsrfToken } from './webauthn_utils' import { base64urlToBuffer, bufferToBase64url, getCsrfToken } from './webauthn_utils'
import type {
WebAuthnChallengeData,
WebAuthnAuthChallengeData,
WebAuthnRegistrationResult,
WebAuthnAuthenticationResult,
ErrorResponse
} from './types/webauthn'
import type { LiveViewHook } from './types/liveview'
const WebAuthn = { const WebAuthn = {
// Registration flow // Registration flow
async register(challengeData, credentialName) { async register(challengeData: WebAuthnChallengeData, credentialName: string): Promise<WebAuthnRegistrationResult> {
// Convert challenge and user ID to ArrayBuffer // Convert challenge and user ID to ArrayBuffer
const challenge = base64urlToBuffer(challengeData.challenge); const challenge = base64urlToBuffer(challengeData.challenge)
const userId = base64urlToBuffer(challengeData.user.id); const userId = base64urlToBuffer(challengeData.user.id)
const publicKeyCredentialCreationOptions = { const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = {
challenge, challenge,
rp: challengeData.rp, rp: challengeData.rp,
user: { user: {
@ -16,16 +24,20 @@ const WebAuthn = {
name: challengeData.user.name, name: challengeData.user.name,
displayName: challengeData.user.displayName displayName: challengeData.user.displayName
}, },
pubKeyCredParams: challengeData.pubKeyCredParams, pubKeyCredParams: challengeData.pubKeyCredParams as PublicKeyCredentialParameters[],
authenticatorSelection: challengeData.authenticatorSelection, authenticatorSelection: challengeData.authenticatorSelection,
timeout: challengeData.timeout, timeout: challengeData.timeout,
attestation: challengeData.attestation attestation: (challengeData.attestation as AttestationConveyancePreference) || 'none'
}; }
try { try {
const credential = await navigator.credentials.create({ const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions publicKey: publicKeyCredentialCreationOptions
}); }) as PublicKeyCredential
if (!credential.response || !(credential.response instanceof AuthenticatorAttestationResponse)) {
throw new Error('Invalid credential response')
}
return { return {
name: credentialName, name: credentialName,
@ -38,35 +50,39 @@ const WebAuthn = {
attestationObject: bufferToBase64url(credential.response.attestationObject) attestationObject: bufferToBase64url(credential.response.attestationObject)
} }
} }
}; }
} catch (error) { } catch (error) {
console.error('WebAuthn registration error:', error); console.error('WebAuthn registration error:', error)
throw error; throw error
} }
}, },
// Authentication flow // Authentication flow
async authenticate(challengeData) { async authenticate(challengeData: WebAuthnAuthChallengeData): Promise<WebAuthnAuthenticationResult> {
const challenge = base64urlToBuffer(challengeData.challenge); const challenge = base64urlToBuffer(challengeData.challenge)
const allowCredentials = challengeData.allowCredentials.map(cred => ({ const allowCredentials: PublicKeyCredentialDescriptor[] = challengeData.allowCredentials.map(cred => ({
type: cred.type, type: cred.type as PublicKeyCredentialType,
id: base64urlToBuffer(cred.id), id: base64urlToBuffer(cred.id),
transports: cred.transports transports: cred.transports as AuthenticatorTransport[]
})); }))
const publicKeyCredentialRequestOptions = { const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
challenge, challenge,
rpId: challengeData.rpId, rpId: challengeData.rpId,
allowCredentials, allowCredentials,
timeout: challengeData.timeout, timeout: challengeData.timeout,
userVerification: challengeData.userVerification userVerification: challengeData.userVerification as UserVerificationRequirement
}; }
try { try {
const assertion = await navigator.credentials.get({ const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions publicKey: publicKeyCredentialRequestOptions
}); }) as PublicKeyCredential
if (!assertion.response || !(assertion.response instanceof AuthenticatorAssertionResponse)) {
throw new Error('Invalid assertion response')
}
return { return {
assertion: { assertion: {
@ -81,18 +97,18 @@ const WebAuthn = {
bufferToBase64url(assertion.response.userHandle) : null bufferToBase64url(assertion.response.userHandle) : null
} }
} }
}; }
} catch (error) { } catch (error) {
console.error('WebAuthn authentication error:', error); console.error('WebAuthn authentication error:', error)
throw error; throw error
} }
} }
}; }
// LiveView Hook for Registration (in credential management page) // LiveView Hook for Registration (in credential management page)
export const WebAuthnRegister = { export const WebAuthnRegister: LiveViewHook = {
mounted() { mounted() {
this.handleEvent("start_registration", async ({ name }) => { this.handleEvent?.("start_registration", async ({ name }: { name: string }) => {
try { try {
// Fetch challenge from server // Fetch challenge from server
const response = await fetch('/api/webauthn/registration/challenge', { const response = await fetch('/api/webauthn/registration/challenge', {
@ -101,18 +117,18 @@ export const WebAuthnRegister = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-Token': getCsrfToken() 'X-CSRF-Token': getCsrfToken()
} }
}); })
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error: ErrorResponse = await response.json()
this.pushEvent("registration_failed", { error: error.error || 'Failed to get registration challenge' }); this.pushEvent?.("registration_failed", { error: error.error || 'Failed to get registration challenge' })
return; return
} }
const challengeData = await response.json(); const challengeData: WebAuthnChallengeData = await response.json()
// Perform WebAuthn registration // Perform WebAuthn registration
const registrationData = await WebAuthn.register(challengeData, name); const registrationData = await WebAuthn.register(challengeData, name)
// Send to server // Send to server
const registerResponse = await fetch('/api/webauthn/register', { const registerResponse = await fetch('/api/webauthn/register', {
@ -122,30 +138,30 @@ export const WebAuthnRegister = {
'X-CSRF-Token': getCsrfToken() 'X-CSRF-Token': getCsrfToken()
}, },
body: JSON.stringify(registrationData) body: JSON.stringify(registrationData)
}); })
if (registerResponse.ok) { if (registerResponse.ok) {
this.pushEvent("registration_success", {}); this.pushEvent?.("registration_success", {})
} else { } else {
const error = await registerResponse.json(); const error: ErrorResponse = await registerResponse.json()
this.pushEvent("registration_failed", { error: error.error || 'Failed to register passkey' }); this.pushEvent?.("registration_failed", { error: error.error || 'Failed to register passkey' })
} }
} catch (error) { } catch (error) {
console.error('Registration error:', error); console.error('Registration error:', error)
this.pushEvent("registration_failed", { this.pushEvent?.("registration_failed", {
error: error.name === 'NotAllowedError' error: (error as Error).name === 'NotAllowedError'
? 'Registration was cancelled or timed out' ? 'Registration was cancelled or timed out'
: error.message || 'Failed to register passkey' : (error as Error).message || 'Failed to register passkey'
}); })
} }
}); })
} }
}; }
// LiveView Hook for Authentication (on login page) // LiveView Hook for Authentication (on login page)
export const WebAuthnLogin = { export const WebAuthnLogin: LiveViewHook = {
mounted() { mounted() {
this.handleEvent("start_authentication", async ({ email }) => { this.handleEvent?.("start_authentication", async ({ email }: { email: string }) => {
try { try {
// Fetch challenge from server // Fetch challenge from server
const response = await fetch('/api/webauthn/authentication/challenge', { const response = await fetch('/api/webauthn/authentication/challenge', {
@ -155,20 +171,20 @@ export const WebAuthnLogin = {
'X-CSRF-Token': getCsrfToken() 'X-CSRF-Token': getCsrfToken()
}, },
body: JSON.stringify({ email }) body: JSON.stringify({ email })
}); })
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error: ErrorResponse = await response.json()
this.pushEvent("authentication_failed", { this.pushEvent?.("authentication_failed", {
error: error.error || 'No passkeys found for this email' error: error.error || 'No passkeys found for this email'
}); })
return; return
} }
const challengeData = await response.json(); const challengeData: WebAuthnAuthChallengeData = await response.json()
// Perform WebAuthn authentication // Perform WebAuthn authentication
const authData = await WebAuthn.authenticate(challengeData); const authData = await WebAuthn.authenticate(challengeData)
// Send to server // Send to server
const authResponse = await fetch('/api/webauthn/authenticate', { const authResponse = await fetch('/api/webauthn/authenticate', {
@ -178,23 +194,23 @@ export const WebAuthnLogin = {
'X-CSRF-Token': getCsrfToken() 'X-CSRF-Token': getCsrfToken()
}, },
body: JSON.stringify(authData) body: JSON.stringify(authData)
}); })
if (authResponse.ok) { if (authResponse.ok) {
const result = await authResponse.json(); const result = await authResponse.json()
// Redirect to the provided URL // Redirect to the provided URL
window.location.href = result.redirect; window.location.href = result.redirect
} else { } else {
this.pushEvent("authentication_failed", { error: 'Authentication failed' }); this.pushEvent?.("authentication_failed", { error: 'Authentication failed' })
} }
} catch (error) { } catch (error) {
console.error('Authentication error:', error); console.error('Authentication error:', error)
this.pushEvent("authentication_failed", { this.pushEvent?.("authentication_failed", {
error: error.name === 'NotAllowedError' error: (error as Error).name === 'NotAllowedError'
? 'Authentication was cancelled or timed out' ? 'Authentication was cancelled or timed out'
: error.message || 'Failed to authenticate' : (error as Error).message || 'Failed to authenticate'
}); })
} }
}); })
} }
}; }

View file

@ -1,7 +1,7 @@
// WebAuthn utility functions // WebAuthn utility functions
// Convert Base64URL string to ArrayBuffer // Convert Base64URL string to ArrayBuffer
export function base64urlToBuffer(base64url) { export function base64urlToBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padLen = (4 - (base64.length % 4)) % 4 const padLen = (4 - (base64.length % 4)) % 4
const padded = base64 + '='.repeat(padLen) const padded = base64 + '='.repeat(padLen)
@ -15,7 +15,7 @@ export function base64urlToBuffer(base64url) {
} }
// Convert ArrayBuffer to Base64URL string // Convert ArrayBuffer to Base64URL string
export function bufferToBase64url(buffer) { export function bufferToBase64url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer) const bytes = new Uint8Array(buffer)
let binary = '' let binary = ''
for (let i = 0; i < bytes.byteLength; i++) { for (let i = 0; i < bytes.byteLength; i++) {
@ -28,6 +28,10 @@ export function bufferToBase64url(buffer) {
} }
// Get CSRF token from meta tag // Get CSRF token from meta tag
export function getCsrfToken() { export function getCsrfToken(): string {
return document.querySelector("meta[name='csrf-token']").getAttribute('content') const metaTag = document.querySelector<HTMLMetaElement>("meta[name='csrf-token']")
if (!metaTag) {
throw new Error('CSRF token meta tag not found')
}
return metaTag.getAttribute('content') || ''
} }

View file

@ -1,32 +1,21 @@
// This file is needed on most editors to enable the intelligent autocompletion
// of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
//
// Note: This file assumes a basic esbuild setup without node_modules.
// We include a generic paths alias to deps to mimic how esbuild resolves
// the Phoenix and LiveView JavaScript assets.
// If you have a package.json in your project, you should remove the
// paths configuration and instead add the phoenix dependencies to the
// dependencies section of your package.json:
//
// {
// ...
// "dependencies": {
// ...,
// "phoenix": "../deps/phoenix",
// "phoenix_html": "../deps/phoenix_html",
// "phoenix_live_view": "../deps/phoenix_live_view"
// }
// }
//
// Feel free to adjust this configuration however you need.
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"noEmit": true,
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"*": ["../deps/*"] "@/*": ["./*"]
}, }
"allowJs": true,
"noEmit": true
}, },
"include": ["js/**/*"] "include": ["js/**/*"],
"exclude": ["node_modules"]
} }

View file

@ -12,7 +12,7 @@ config :esbuild,
version: "0.25.4", version: "0.25.4",
towerops: [ towerops: [
args: args:
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.), ~w(js/app.ts --bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/* --alias:@=.),
cd: Path.expand("../assets", __DIR__), cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]} env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
] ]