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:
parent
2bacf6337c
commit
ab6ecbdfdc
11 changed files with 468 additions and 178 deletions
|
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
17
assets/js/types/liveview.d.ts
vendored
Normal 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
136
assets/js/types/vendor.d.ts
vendored
Normal 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
73
assets/js/types/webauthn.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
@ -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') || ''
|
||||||
}
|
}
|
||||||
|
|
@ -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"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()]}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue