Convert JavaScript to TypeScript with proper type definitions

- Add comprehensive type definitions for vendor libraries (Chart.js, topbar)
- Add TypeScript types for LiveView hooks and WebAuthn interfaces
- Convert all JS files to TypeScript with proper typing:
  - app.js → app.ts
  - webauthn.js → webauthn.ts
  - webauthn_utils.js → webauthn_utils.ts
  - passkey_settings.js → passkey_settings.ts
  - passkey_login.js → passkey_login.ts
  - passkey_discoverable.js → passkey_discoverable.ts
- Update tsconfig.json with modern ES2022 and strict type checking
- Update esbuild config to use app.ts as entry point
- All assets compile successfully with esbuild's native TypeScript support
- No node_modules required, following Phoenix/Elixir conventions
This commit is contained in:
Graham McIntire 2026-01-15 13:29:49 -06:00
parent 2bacf6337c
commit ab6ecbdfdc
No known key found for this signature in database
11 changed files with 468 additions and 178 deletions

View file

@ -20,18 +20,20 @@
// Include phoenix_html to handle method=PUT/DELETE in forms and buttons.
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
})
}

View file

@ -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)

View file

@ -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)

View file

@ -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
View file

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

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

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

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

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

View file

@ -1,14 +1,22 @@
// WebAuthn utility functions for passkey authentication
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'
})
}
});
})
}
};
}

View file

@ -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') || ''
}

View file

@ -1,32 +1,21 @@
// This file is needed on most editors to enable the intelligent autocompletion
// of LiveView's JavaScript API methods. You can safely delete it if you don't need it.
//
// Note: This file assumes a basic esbuild setup without node_modules.
// We include a generic paths alias to deps to mimic how esbuild resolves
// the Phoenix and LiveView JavaScript assets.
// If you have a package.json in your project, you should remove the
// paths configuration and instead add the phoenix dependencies to the
// dependencies section of your package.json:
//
// {
// ...
// "dependencies": {
// ...,
// "phoenix": "../deps/phoenix",
// "phoenix_html": "../deps/phoenix_html",
// "phoenix_live_view": "../deps/phoenix_live_view"
// }
// }
//
// Feel free to adjust this configuration however you need.
{
"compilerOptions": {
"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"]
}

View file

@ -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()]}
]