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.
|
||||
import "phoenix_html"
|
||||
// Establish Phoenix Socket and LiveView configuration.
|
||||
import {Socket} from "phoenix"
|
||||
import {LiveSocket} from "phoenix_live_view"
|
||||
import {hooks as colocatedHooks} from "phoenix-colocated/towerops"
|
||||
import { Socket } from "phoenix"
|
||||
import { LiveSocket } from "phoenix_live_view"
|
||||
import { hooks as colocatedHooks } from "phoenix-colocated/towerops"
|
||||
import topbar from "../vendor/topbar"
|
||||
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_login"
|
||||
import "./passkey_discoverable"
|
||||
import type { SensorChartHook } from "./types/liveview"
|
||||
|
||||
// Generic Sensor Chart Hook (for CPU, Memory, Storage, etc.)
|
||||
const SensorChart = {
|
||||
const SensorChart: SensorChartHook = {
|
||||
mounted() {
|
||||
this.createChart()
|
||||
},
|
||||
|
|
@ -51,12 +53,21 @@ const SensorChart = {
|
|||
},
|
||||
|
||||
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')
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const unit = this.el.dataset.unit || '%'
|
||||
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 autoScale = this.el.dataset.autoScale === 'true' || this.el.dataset.autoScale === 'true'
|
||||
const showZeroLine = this.el.dataset.showZeroLine === 'true' || this.el.dataset.showZeroLine === 'true'
|
||||
const chartType = unit === 'bps' ? 'bar' : 'line'
|
||||
|
||||
// Generate colors for each dataset
|
||||
|
|
@ -71,8 +82,8 @@ const SensorChart = {
|
|||
'rgb(249, 115, 22)', // orange
|
||||
]
|
||||
|
||||
const datasets = data.datasets.map((dataset, index) => {
|
||||
const baseConfig = {
|
||||
const datasets: ChartDataset[] = data.datasets.map((dataset, index) => {
|
||||
const baseConfig: ChartDataset = {
|
||||
...dataset,
|
||||
borderColor: colors[index % colors.length],
|
||||
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
|
||||
const formatBps = (bps) => {
|
||||
const formatBps = (bps: number): string => {
|
||||
// Use absolute value for display (inbound is negative, but we show it as positive)
|
||||
const absBps = Math.abs(bps)
|
||||
if (absBps >= 1_000_000_000) {
|
||||
|
|
@ -105,14 +116,14 @@ const SensorChart = {
|
|||
}
|
||||
|
||||
// For traffic graphs, calculate symmetric scale to keep zero in the middle
|
||||
let yAxisConfig = {}
|
||||
let yAxisConfig: any = {}
|
||||
if (autoScale) {
|
||||
if (showZeroLine && unit === 'bps') {
|
||||
// Find max absolute value to make symmetric scale
|
||||
let maxAbsValue = 0
|
||||
datasets.forEach(dataset => {
|
||||
if (dataset.data && dataset.data.length > 0) {
|
||||
dataset.data.forEach(point => {
|
||||
dataset.data.forEach((point: ChartDataPoint) => {
|
||||
if (point && typeof point.y === 'number') {
|
||||
maxAbsValue = Math.max(maxAbsValue, Math.abs(point.y))
|
||||
}
|
||||
|
|
@ -126,19 +137,19 @@ const SensorChart = {
|
|||
min: -maxAbsValue,
|
||||
max: maxAbsValue,
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
callback: function(value: number) {
|
||||
return formatBps(value)
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: function(context) {
|
||||
color: function(context: any) {
|
||||
// Make the zero line more prominent
|
||||
if (context.tick.value === 0) {
|
||||
return 'rgba(0, 0, 0, 0.3)'
|
||||
}
|
||||
return 'rgba(0, 0, 0, 0.05)'
|
||||
},
|
||||
lineWidth: function(context) {
|
||||
lineWidth: function(context: any) {
|
||||
// Make the zero line thicker
|
||||
if (context.tick.value === 0) {
|
||||
return 2
|
||||
|
|
@ -151,7 +162,7 @@ const SensorChart = {
|
|||
yAxisConfig = {
|
||||
beginAtZero: showZeroLine,
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
callback: function(value: number) {
|
||||
if (unit === 'bps') {
|
||||
return formatBps(value)
|
||||
}
|
||||
|
|
@ -159,14 +170,14 @@ const SensorChart = {
|
|||
}
|
||||
},
|
||||
grid: {
|
||||
color: function(context) {
|
||||
color: function(context: any) {
|
||||
// Make the zero line more prominent
|
||||
if (showZeroLine && context.tick.value === 0) {
|
||||
return 'rgba(0, 0, 0, 0.3)'
|
||||
}
|
||||
return 'rgba(0, 0, 0, 0.05)'
|
||||
},
|
||||
lineWidth: function(context) {
|
||||
lineWidth: function(context: any) {
|
||||
// Make the zero line thicker
|
||||
if (showZeroLine && context.tick.value === 0) {
|
||||
return 2
|
||||
|
|
@ -181,7 +192,7 @@ const SensorChart = {
|
|||
min: 0,
|
||||
max: 100,
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
callback: function(value: number) {
|
||||
if (unit === 'bps') {
|
||||
return formatBps(value)
|
||||
}
|
||||
|
|
@ -221,7 +232,7 @@ const SensorChart = {
|
|||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: function(context) {
|
||||
title: function(context: any) {
|
||||
const timestamp = context[0].parsed.x
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleString('en-US', {
|
||||
|
|
@ -232,7 +243,7 @@ const SensorChart = {
|
|||
hour12: false
|
||||
})
|
||||
},
|
||||
label: function(context) {
|
||||
label: function(context: any) {
|
||||
if (unit === 'bps') {
|
||||
return context.dataset.label + ': ' + formatBps(context.parsed.y)
|
||||
}
|
||||
|
|
@ -247,7 +258,7 @@ const SensorChart = {
|
|||
min: twentyFourHoursAgo,
|
||||
max: now,
|
||||
ticks: {
|
||||
callback: function(value) {
|
||||
callback: function(value: number) {
|
||||
const date = new Date(value)
|
||||
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, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {_csrf_token: csrfToken},
|
||||
hooks: {...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin},
|
||||
params: { _csrf_token: csrfToken },
|
||||
hooks: { ...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin },
|
||||
})
|
||||
|
||||
// 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-stop", _info => topbar.hide())
|
||||
|
||||
// Handle clipboard copy events
|
||||
window.addEventListener("phx:copy", (event) => {
|
||||
const el = event.target
|
||||
const el = event.target as HTMLElement
|
||||
|
||||
let text = ""
|
||||
|
||||
if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") {
|
||||
text = el.value
|
||||
text = (el as HTMLInputElement | HTMLTextAreaElement).value
|
||||
} else {
|
||||
text = el.innerText || el.textContent
|
||||
text = el.innerText || el.textContent || ""
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
|
|
@ -302,6 +318,13 @@ liveSocket.connect()
|
|||
// >> liveSocket.enableDebug()
|
||||
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
|
||||
// >> liveSocket.disableLatencySim()
|
||||
declare global {
|
||||
interface Window {
|
||||
liveSocket: LiveSocket
|
||||
liveReloader?: any
|
||||
}
|
||||
}
|
||||
|
||||
window.liveSocket = liveSocket
|
||||
|
||||
// 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
|
||||
//
|
||||
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.
|
||||
// Disable with reloader.disableServerLogs()
|
||||
reloader.enableServerLogs()
|
||||
|
|
@ -320,15 +343,15 @@ if (process.env.NODE_ENV === "development") {
|
|||
//
|
||||
// * click with "c" key pressed to open at caller 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("keyup", _e => keyDown = null)
|
||||
window.addEventListener("click", e => {
|
||||
if(keyDown === "c"){
|
||||
if (keyDown === "c") {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
reloader.openEditorAtCaller(e.target)
|
||||
} else if(keyDown === "d"){
|
||||
} else if (keyDown === "d") {
|
||||
e.preventDefault()
|
||||
e.stopImmediatePropagation()
|
||||
reloader.openEditorAtDef(e.target)
|
||||
|
|
@ -338,4 +361,3 @@ if (process.env.NODE_ENV === "development") {
|
|||
window.liveReloader = reloader
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1,20 +1,27 @@
|
|||
// Discoverable passkey authentication (usernameless login)
|
||||
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
|
||||
import type { WebAuthnAuthChallengeData, ErrorResponse, AuthSuccessResponse } from './types/webauthn'
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const discoverableBtn = document.getElementById('passkey-discoverable-btn')
|
||||
const discoverableBtn = document.getElementById('passkey-discoverable-btn') as HTMLButtonElement | null
|
||||
const errorDiv = document.getElementById('passkey-discoverable-error')
|
||||
|
||||
if (!discoverableBtn || !errorDiv) return
|
||||
|
||||
const showError = (message) => {
|
||||
const showError = (message: string) => {
|
||||
errorDiv.classList.remove('hidden')
|
||||
errorDiv.querySelector('p').textContent = message
|
||||
const errorText = errorDiv.querySelector('p')
|
||||
if (errorText) {
|
||||
errorText.textContent = message
|
||||
}
|
||||
}
|
||||
|
||||
const hideError = () => {
|
||||
errorDiv.classList.add('hidden')
|
||||
errorDiv.querySelector('p').textContent = ''
|
||||
const errorText = errorDiv.querySelector('p')
|
||||
if (errorText) {
|
||||
errorText.textContent = ''
|
||||
}
|
||||
}
|
||||
|
||||
const authenticateWithDiscoverablePasskey = async () => {
|
||||
|
|
@ -33,28 +40,32 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
})
|
||||
|
||||
if (!challengeResponse.ok) {
|
||||
const error = await challengeResponse.json()
|
||||
const error: ErrorResponse = await challengeResponse.json()
|
||||
showError(error.error || 'Failed to get authentication challenge')
|
||||
return
|
||||
}
|
||||
|
||||
const challengeData = await challengeResponse.json()
|
||||
const challengeData: WebAuthnAuthChallengeData = await challengeResponse.json()
|
||||
|
||||
// Convert challenge to ArrayBuffer
|
||||
const challenge = base64urlToBuffer(challengeData.challenge)
|
||||
|
||||
const publicKeyCredentialRequestOptions = {
|
||||
const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
|
||||
challenge,
|
||||
rpId: challengeData.rpId,
|
||||
timeout: challengeData.timeout,
|
||||
userVerification: challengeData.userVerification
|
||||
userVerification: challengeData.userVerification as UserVerificationRequirement
|
||||
// No allowCredentials - let the authenticator show all credentials for this RP ID
|
||||
}
|
||||
|
||||
// Get assertion from authenticator
|
||||
const assertion = await navigator.credentials.get({
|
||||
publicKey: publicKeyCredentialRequestOptions
|
||||
})
|
||||
}) as PublicKeyCredential
|
||||
|
||||
if (!assertion.response || !(assertion.response instanceof AuthenticatorAssertionResponse)) {
|
||||
throw new Error('Invalid assertion response')
|
||||
}
|
||||
|
||||
const authData = {
|
||||
assertion: {
|
||||
|
|
@ -82,7 +93,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
})
|
||||
|
||||
if (authResponse.ok) {
|
||||
const result = await authResponse.json()
|
||||
const result: AuthSuccessResponse = await authResponse.json()
|
||||
// Redirect to the provided URL
|
||||
window.location.href = result.redirect
|
||||
} else {
|
||||
|
|
@ -92,12 +103,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
console.error('Discoverable authentication error:', error)
|
||||
|
||||
let errorMessage = 'Failed to authenticate'
|
||||
if (error.name === 'NotAllowedError') {
|
||||
if ((error as Error).name === 'NotAllowedError') {
|
||||
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'
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message
|
||||
} else if ((error as Error).message) {
|
||||
errorMessage = (error as Error).message
|
||||
}
|
||||
|
||||
showError(errorMessage)
|
||||
|
|
@ -1,21 +1,28 @@
|
|||
// Passkey authentication for login page
|
||||
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
|
||||
import type { WebAuthnAuthChallengeData, ErrorResponse, AuthSuccessResponse } from './types/webauthn'
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const passkeyLoginBtn = document.getElementById('passkey-login-btn')
|
||||
const emailInput = document.querySelector('#passkey_login_form input[type="email"]')
|
||||
const passkeyLoginBtn = document.getElementById('passkey-login-btn') as HTMLButtonElement | null
|
||||
const emailInput = document.querySelector<HTMLInputElement>('#passkey_login_form input[type="email"]')
|
||||
const errorDiv = document.getElementById('passkey-login-error')
|
||||
|
||||
if (!passkeyLoginBtn || !emailInput || !errorDiv) return
|
||||
|
||||
const showError = (message) => {
|
||||
const showError = (message: string) => {
|
||||
errorDiv.classList.remove('hidden')
|
||||
errorDiv.querySelector('p').textContent = message
|
||||
const errorText = errorDiv.querySelector('p')
|
||||
if (errorText) {
|
||||
errorText.textContent = message
|
||||
}
|
||||
}
|
||||
|
||||
const hideError = () => {
|
||||
errorDiv.classList.add('hidden')
|
||||
errorDiv.querySelector('p').textContent = ''
|
||||
const errorText = errorDiv.querySelector('p')
|
||||
if (errorText) {
|
||||
errorText.textContent = ''
|
||||
}
|
||||
}
|
||||
|
||||
const authenticateWithPasskey = async () => {
|
||||
|
|
@ -43,34 +50,38 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
})
|
||||
|
||||
if (!challengeResponse.ok) {
|
||||
const error = await challengeResponse.json()
|
||||
const error: ErrorResponse = await challengeResponse.json()
|
||||
showError(error.error || 'No passkeys found for this email')
|
||||
return
|
||||
}
|
||||
|
||||
const challengeData = await challengeResponse.json()
|
||||
const challengeData: WebAuthnAuthChallengeData = await challengeResponse.json()
|
||||
|
||||
// Convert challenge to ArrayBuffer
|
||||
const challenge = base64urlToBuffer(challengeData.challenge)
|
||||
|
||||
const allowCredentials = challengeData.allowCredentials.map(cred => ({
|
||||
type: cred.type,
|
||||
const allowCredentials: PublicKeyCredentialDescriptor[] = challengeData.allowCredentials.map(cred => ({
|
||||
type: cred.type as PublicKeyCredentialType,
|
||||
id: base64urlToBuffer(cred.id),
|
||||
transports: cred.transports
|
||||
transports: cred.transports as AuthenticatorTransport[]
|
||||
}))
|
||||
|
||||
const publicKeyCredentialRequestOptions = {
|
||||
const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
|
||||
challenge,
|
||||
rpId: challengeData.rpId,
|
||||
allowCredentials,
|
||||
timeout: challengeData.timeout,
|
||||
userVerification: challengeData.userVerification
|
||||
userVerification: challengeData.userVerification as UserVerificationRequirement
|
||||
}
|
||||
|
||||
// Get assertion from authenticator
|
||||
const assertion = await navigator.credentials.get({
|
||||
publicKey: publicKeyCredentialRequestOptions
|
||||
})
|
||||
}) as PublicKeyCredential
|
||||
|
||||
if (!assertion.response || !(assertion.response instanceof AuthenticatorAssertionResponse)) {
|
||||
throw new Error('Invalid assertion response')
|
||||
}
|
||||
|
||||
const authData = {
|
||||
assertion: {
|
||||
|
|
@ -98,7 +109,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
})
|
||||
|
||||
if (authResponse.ok) {
|
||||
const result = await authResponse.json()
|
||||
const result: AuthSuccessResponse = await authResponse.json()
|
||||
// Redirect to the provided URL
|
||||
window.location.href = result.redirect
|
||||
} else {
|
||||
|
|
@ -108,10 +119,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
console.error('Authentication error:', error)
|
||||
|
||||
let errorMessage = 'Failed to authenticate'
|
||||
if (error.name === 'NotAllowedError') {
|
||||
if ((error as Error).name === 'NotAllowedError') {
|
||||
errorMessage = 'Authentication was cancelled or timed out'
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message
|
||||
} else if ((error as Error).message) {
|
||||
errorMessage = (error as Error).message
|
||||
}
|
||||
|
||||
showError(errorMessage)
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
// Passkey management for settings page
|
||||
import { bufferToBase64url, base64urlToBuffer, getCsrfToken } from './webauthn_utils'
|
||||
import type { WebAuthnChallengeData, ErrorResponse } from './types/webauthn'
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const addPasskeyBtn = document.getElementById('add-passkey-btn')
|
||||
const modal = document.getElementById('passkey-modal')
|
||||
const nameInput = document.getElementById('passkey-name-input')
|
||||
const confirmBtn = document.getElementById('confirm-add-passkey')
|
||||
const nameInput = document.getElementById('passkey-name-input') as HTMLInputElement | null
|
||||
const confirmBtn = document.getElementById('confirm-add-passkey') as HTMLButtonElement | null
|
||||
const cancelBtn = document.getElementById('cancel-add-passkey')
|
||||
const errorDiv = document.getElementById('passkey-error')
|
||||
|
||||
if (!addPasskeyBtn || !modal) return
|
||||
if (!addPasskeyBtn || !modal || !nameInput || !confirmBtn || !cancelBtn || !errorDiv) return
|
||||
|
||||
// Show modal when clicking "Add Passkey"
|
||||
addPasskeyBtn.addEventListener('click', () => {
|
||||
|
|
@ -28,7 +29,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
|
||||
cancelBtn.addEventListener('click', hideModal)
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
|
@ -41,14 +42,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
})
|
||||
|
||||
// Show error in modal
|
||||
const showError = (message) => {
|
||||
const showError = (message: string) => {
|
||||
errorDiv.classList.remove('hidden')
|
||||
errorDiv.querySelector('p').textContent = message
|
||||
const errorText = errorDiv.querySelector('p')
|
||||
if (errorText) {
|
||||
errorText.textContent = message
|
||||
}
|
||||
}
|
||||
|
||||
const hideError = () => {
|
||||
errorDiv.classList.add('hidden')
|
||||
errorDiv.querySelector('p').textContent = ''
|
||||
const errorText = errorDiv.querySelector('p')
|
||||
if (errorText) {
|
||||
errorText.textContent = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Handle passkey registration
|
||||
|
|
@ -75,18 +82,18 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
})
|
||||
|
||||
if (!challengeResponse.ok) {
|
||||
const error = await challengeResponse.json()
|
||||
const error: ErrorResponse = await challengeResponse.json()
|
||||
showError(error.error || 'Failed to get registration challenge')
|
||||
return
|
||||
}
|
||||
|
||||
const challengeData = await challengeResponse.json()
|
||||
const challengeData: WebAuthnChallengeData = await challengeResponse.json()
|
||||
|
||||
// Convert challenge and user ID to ArrayBuffer
|
||||
const challenge = base64urlToBuffer(challengeData.challenge)
|
||||
const userId = base64urlToBuffer(challengeData.user.id)
|
||||
|
||||
const publicKeyCredentialCreationOptions = {
|
||||
const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = {
|
||||
challenge,
|
||||
rp: challengeData.rp,
|
||||
user: {
|
||||
|
|
@ -94,10 +101,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
name: challengeData.user.name,
|
||||
displayName: challengeData.user.displayName
|
||||
},
|
||||
pubKeyCredParams: challengeData.pubKeyCredParams,
|
||||
pubKeyCredParams: challengeData.pubKeyCredParams as PublicKeyCredentialParameters[],
|
||||
authenticatorSelection: challengeData.authenticatorSelection,
|
||||
timeout: challengeData.timeout,
|
||||
attestation: challengeData.attestation
|
||||
attestation: (challengeData.attestation as AttestationConveyancePreference) || 'none'
|
||||
}
|
||||
|
||||
// Hide modal before showing authenticator prompt
|
||||
|
|
@ -106,7 +113,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
// Create credential
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: publicKeyCredentialCreationOptions
|
||||
})
|
||||
}) as PublicKeyCredential
|
||||
|
||||
if (!credential.response || !(credential.response instanceof AuthenticatorAttestationResponse)) {
|
||||
throw new Error('Invalid credential response')
|
||||
}
|
||||
|
||||
const registrationData = {
|
||||
name: name,
|
||||
|
|
@ -135,7 +146,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
// Reload page to show new passkey
|
||||
window.location.reload()
|
||||
} else {
|
||||
const error = await registerResponse.json()
|
||||
const error: ErrorResponse = await registerResponse.json()
|
||||
modal.classList.remove('hidden')
|
||||
showError(error.error || 'Failed to register passkey')
|
||||
}
|
||||
|
|
@ -143,10 +154,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
console.error('Registration error:', error)
|
||||
|
||||
let errorMessage = 'Failed to register passkey'
|
||||
if (error.name === 'NotAllowedError') {
|
||||
if ((error as Error).name === 'NotAllowedError') {
|
||||
errorMessage = 'Registration was cancelled or timed out'
|
||||
} else if (error.message) {
|
||||
errorMessage = error.message
|
||||
} else if ((error as Error).message) {
|
||||
errorMessage = (error as Error).message
|
||||
}
|
||||
|
||||
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
|
||||
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 = {
|
||||
// Registration flow
|
||||
async register(challengeData, credentialName) {
|
||||
async register(challengeData: WebAuthnChallengeData, credentialName: string): Promise<WebAuthnRegistrationResult> {
|
||||
// Convert challenge and user ID to ArrayBuffer
|
||||
const challenge = base64urlToBuffer(challengeData.challenge);
|
||||
const userId = base64urlToBuffer(challengeData.user.id);
|
||||
const challenge = base64urlToBuffer(challengeData.challenge)
|
||||
const userId = base64urlToBuffer(challengeData.user.id)
|
||||
|
||||
const publicKeyCredentialCreationOptions = {
|
||||
const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = {
|
||||
challenge,
|
||||
rp: challengeData.rp,
|
||||
user: {
|
||||
|
|
@ -16,16 +24,20 @@ const WebAuthn = {
|
|||
name: challengeData.user.name,
|
||||
displayName: challengeData.user.displayName
|
||||
},
|
||||
pubKeyCredParams: challengeData.pubKeyCredParams,
|
||||
pubKeyCredParams: challengeData.pubKeyCredParams as PublicKeyCredentialParameters[],
|
||||
authenticatorSelection: challengeData.authenticatorSelection,
|
||||
timeout: challengeData.timeout,
|
||||
attestation: challengeData.attestation
|
||||
};
|
||||
attestation: (challengeData.attestation as AttestationConveyancePreference) || 'none'
|
||||
}
|
||||
|
||||
try {
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: publicKeyCredentialCreationOptions
|
||||
});
|
||||
}) as PublicKeyCredential
|
||||
|
||||
if (!credential.response || !(credential.response instanceof AuthenticatorAttestationResponse)) {
|
||||
throw new Error('Invalid credential response')
|
||||
}
|
||||
|
||||
return {
|
||||
name: credentialName,
|
||||
|
|
@ -38,35 +50,39 @@ const WebAuthn = {
|
|||
attestationObject: bufferToBase64url(credential.response.attestationObject)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('WebAuthn registration error:', error);
|
||||
throw error;
|
||||
console.error('WebAuthn registration error:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// Authentication flow
|
||||
async authenticate(challengeData) {
|
||||
const challenge = base64urlToBuffer(challengeData.challenge);
|
||||
async authenticate(challengeData: WebAuthnAuthChallengeData): Promise<WebAuthnAuthenticationResult> {
|
||||
const challenge = base64urlToBuffer(challengeData.challenge)
|
||||
|
||||
const allowCredentials = challengeData.allowCredentials.map(cred => ({
|
||||
type: cred.type,
|
||||
const allowCredentials: PublicKeyCredentialDescriptor[] = challengeData.allowCredentials.map(cred => ({
|
||||
type: cred.type as PublicKeyCredentialType,
|
||||
id: base64urlToBuffer(cred.id),
|
||||
transports: cred.transports
|
||||
}));
|
||||
transports: cred.transports as AuthenticatorTransport[]
|
||||
}))
|
||||
|
||||
const publicKeyCredentialRequestOptions = {
|
||||
const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
|
||||
challenge,
|
||||
rpId: challengeData.rpId,
|
||||
allowCredentials,
|
||||
timeout: challengeData.timeout,
|
||||
userVerification: challengeData.userVerification
|
||||
};
|
||||
userVerification: challengeData.userVerification as UserVerificationRequirement
|
||||
}
|
||||
|
||||
try {
|
||||
const assertion = await navigator.credentials.get({
|
||||
publicKey: publicKeyCredentialRequestOptions
|
||||
});
|
||||
}) as PublicKeyCredential
|
||||
|
||||
if (!assertion.response || !(assertion.response instanceof AuthenticatorAssertionResponse)) {
|
||||
throw new Error('Invalid assertion response')
|
||||
}
|
||||
|
||||
return {
|
||||
assertion: {
|
||||
|
|
@ -81,18 +97,18 @@ const WebAuthn = {
|
|||
bufferToBase64url(assertion.response.userHandle) : null
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('WebAuthn authentication error:', error);
|
||||
throw error;
|
||||
console.error('WebAuthn authentication error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// LiveView Hook for Registration (in credential management page)
|
||||
export const WebAuthnRegister = {
|
||||
export const WebAuthnRegister: LiveViewHook = {
|
||||
mounted() {
|
||||
this.handleEvent("start_registration", async ({ name }) => {
|
||||
this.handleEvent?.("start_registration", async ({ name }: { name: string }) => {
|
||||
try {
|
||||
// Fetch challenge from server
|
||||
const response = await fetch('/api/webauthn/registration/challenge', {
|
||||
|
|
@ -101,18 +117,18 @@ export const WebAuthnRegister = {
|
|||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': getCsrfToken()
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
this.pushEvent("registration_failed", { error: error.error || 'Failed to get registration challenge' });
|
||||
return;
|
||||
const error: ErrorResponse = await response.json()
|
||||
this.pushEvent?.("registration_failed", { error: error.error || 'Failed to get registration challenge' })
|
||||
return
|
||||
}
|
||||
|
||||
const challengeData = await response.json();
|
||||
const challengeData: WebAuthnChallengeData = await response.json()
|
||||
|
||||
// Perform WebAuthn registration
|
||||
const registrationData = await WebAuthn.register(challengeData, name);
|
||||
const registrationData = await WebAuthn.register(challengeData, name)
|
||||
|
||||
// Send to server
|
||||
const registerResponse = await fetch('/api/webauthn/register', {
|
||||
|
|
@ -122,30 +138,30 @@ export const WebAuthnRegister = {
|
|||
'X-CSRF-Token': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify(registrationData)
|
||||
});
|
||||
})
|
||||
|
||||
if (registerResponse.ok) {
|
||||
this.pushEvent("registration_success", {});
|
||||
this.pushEvent?.("registration_success", {})
|
||||
} else {
|
||||
const error = await registerResponse.json();
|
||||
this.pushEvent("registration_failed", { error: error.error || 'Failed to register passkey' });
|
||||
const error: ErrorResponse = await registerResponse.json()
|
||||
this.pushEvent?.("registration_failed", { error: error.error || 'Failed to register passkey' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
this.pushEvent("registration_failed", {
|
||||
error: error.name === 'NotAllowedError'
|
||||
console.error('Registration error:', error)
|
||||
this.pushEvent?.("registration_failed", {
|
||||
error: (error as Error).name === 'NotAllowedError'
|
||||
? '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)
|
||||
export const WebAuthnLogin = {
|
||||
export const WebAuthnLogin: LiveViewHook = {
|
||||
mounted() {
|
||||
this.handleEvent("start_authentication", async ({ email }) => {
|
||||
this.handleEvent?.("start_authentication", async ({ email }: { email: string }) => {
|
||||
try {
|
||||
// Fetch challenge from server
|
||||
const response = await fetch('/api/webauthn/authentication/challenge', {
|
||||
|
|
@ -155,20 +171,20 @@ export const WebAuthnLogin = {
|
|||
'X-CSRF-Token': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify({ email })
|
||||
});
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
this.pushEvent("authentication_failed", {
|
||||
const error: ErrorResponse = await response.json()
|
||||
this.pushEvent?.("authentication_failed", {
|
||||
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
|
||||
const authData = await WebAuthn.authenticate(challengeData);
|
||||
const authData = await WebAuthn.authenticate(challengeData)
|
||||
|
||||
// Send to server
|
||||
const authResponse = await fetch('/api/webauthn/authenticate', {
|
||||
|
|
@ -178,23 +194,23 @@ export const WebAuthnLogin = {
|
|||
'X-CSRF-Token': getCsrfToken()
|
||||
},
|
||||
body: JSON.stringify(authData)
|
||||
});
|
||||
})
|
||||
|
||||
if (authResponse.ok) {
|
||||
const result = await authResponse.json();
|
||||
const result = await authResponse.json()
|
||||
// Redirect to the provided URL
|
||||
window.location.href = result.redirect;
|
||||
window.location.href = result.redirect
|
||||
} else {
|
||||
this.pushEvent("authentication_failed", { error: 'Authentication failed' });
|
||||
this.pushEvent?.("authentication_failed", { error: 'Authentication failed' })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Authentication error:', error);
|
||||
this.pushEvent("authentication_failed", {
|
||||
error: error.name === 'NotAllowedError'
|
||||
console.error('Authentication error:', error)
|
||||
this.pushEvent?.("authentication_failed", {
|
||||
error: (error as Error).name === 'NotAllowedError'
|
||||
? '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
|
||||
|
||||
// Convert Base64URL string to ArrayBuffer
|
||||
export function base64urlToBuffer(base64url) {
|
||||
export function base64urlToBuffer(base64url: string): ArrayBuffer {
|
||||
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padLen = (4 - (base64.length % 4)) % 4
|
||||
const padded = base64 + '='.repeat(padLen)
|
||||
|
|
@ -15,7 +15,7 @@ export function base64urlToBuffer(base64url) {
|
|||
}
|
||||
|
||||
// Convert ArrayBuffer to Base64URL string
|
||||
export function bufferToBase64url(buffer) {
|
||||
export function bufferToBase64url(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer)
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
|
|
@ -28,6 +28,10 @@ export function bufferToBase64url(buffer) {
|
|||
}
|
||||
|
||||
// Get CSRF token from meta tag
|
||||
export function getCsrfToken() {
|
||||
return document.querySelector("meta[name='csrf-token']").getAttribute('content')
|
||||
export function getCsrfToken(): string {
|
||||
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": {
|
||||
"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": ".",
|
||||
"paths": {
|
||||
"*": ["../deps/*"]
|
||||
},
|
||||
"allowJs": true,
|
||||
"noEmit": true
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["js/**/*"]
|
||||
"include": ["js/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ config :esbuild,
|
|||
version: "0.25.4",
|
||||
towerops: [
|
||||
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__),
|
||||
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue