diff --git a/assets/js/app.js b/assets/js/app.ts similarity index 80% rename from assets/js/app.js rename to assets/js/app.ts index 86955cbc..9d599eb6 100644 --- a/assets/js/app.js +++ b/assets/js/app.ts @@ -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("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 }) } - diff --git a/assets/js/passkey_discoverable.js b/assets/js/passkey_discoverable.ts similarity index 72% rename from assets/js/passkey_discoverable.js rename to assets/js/passkey_discoverable.ts index 0fa889a5..47498b2b 100644 --- a/assets/js/passkey_discoverable.js +++ b/assets/js/passkey_discoverable.ts @@ -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) diff --git a/assets/js/passkey_login.js b/assets/js/passkey_login.ts similarity index 69% rename from assets/js/passkey_login.js rename to assets/js/passkey_login.ts index 54c53507..33334012 100644 --- a/assets/js/passkey_login.js +++ b/assets/js/passkey_login.ts @@ -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('#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) diff --git a/assets/js/passkey_settings.js b/assets/js/passkey_settings.ts similarity index 75% rename from assets/js/passkey_settings.js rename to assets/js/passkey_settings.ts index 65107893..094e501c 100644 --- a/assets/js/passkey_settings.js +++ b/assets/js/passkey_settings.ts @@ -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') diff --git a/assets/js/types/liveview.d.ts b/assets/js/types/liveview.d.ts new file mode 100644 index 00000000..b13bfd8c --- /dev/null +++ b/assets/js/types/liveview.d.ts @@ -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; + pushEvent?(event: string, payload: any): void; +} + +export interface SensorChartHook extends LiveViewHook { + chart?: any; + createChart(): void; +} diff --git a/assets/js/types/vendor.d.ts b/assets/js/types/vendor.d.ts new file mode 100644 index 00000000..4a5157fe --- /dev/null +++ b/assets/js/types/vendor.d.ts @@ -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; + } + ); + } +} + +declare module "phoenix-colocated/towerops" { + export const hooks: Record; +} diff --git a/assets/js/types/webauthn.d.ts b/assets/js/types/webauthn.d.ts new file mode 100644 index 00000000..e336094c --- /dev/null +++ b/assets/js/types/webauthn.d.ts @@ -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; +} diff --git a/assets/js/webauthn.js b/assets/js/webauthn.ts similarity index 54% rename from assets/js/webauthn.js rename to assets/js/webauthn.ts index 1d560cb3..cc725c15 100644 --- a/assets/js/webauthn.js +++ b/assets/js/webauthn.ts @@ -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 { // 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 { + 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' + }) } - }); + }) } -}; +} diff --git a/assets/js/webauthn_utils.js b/assets/js/webauthn_utils.ts similarity index 67% rename from assets/js/webauthn_utils.js rename to assets/js/webauthn_utils.ts index fac19e13..1008c136 100644 --- a/assets/js/webauthn_utils.js +++ b/assets/js/webauthn_utils.ts @@ -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("meta[name='csrf-token']") + if (!metaTag) { + throw new Error('CSRF token meta tag not found') + } + return metaTag.getAttribute('content') || '' } diff --git a/assets/tsconfig.json b/assets/tsconfig.json index a9401b62..386ea692 100644 --- a/assets/tsconfig.json +++ b/assets/tsconfig.json @@ -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"] } diff --git a/config/config.exs b/config/config.exs index 642c5b0c..e95f0376 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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()]} ]