Create a stand-alone paskia npm package (paskia-js). Make the frontend use it (but from source tree to keep synced).

This commit is contained in:
Leo Vasanko
2026-01-29 19:46:26 +00:00
parent 9b9086b52e
commit 5fc5226480
21 changed files with 438 additions and 166 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ import CredentialList from '@/components/CredentialList.vue'
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
import SessionList from '@/components/SessionList.vue'
import { useAuthStore } from '@/stores/auth'
import { apiJson } from '@/paskia/fetch'
import { apiJson } from 'paskia'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
const props = defineProps({
+1 -1
View File
@@ -127,7 +127,7 @@ import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings'
import passkey from '@/utils/passkey'
import { goBack } from '@/utils/helpers'
import { apiJson } from '@/paskia/fetch'
import { apiJson } from 'paskia'
import { navigateButtonRow, focusPreferred, focusAtIndex, getDirection } from '@/utils/keynav'
const authStore = useAuthStore()
@@ -35,7 +35,7 @@
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
import QRCodeDisplay from '@/components/QRCodeDisplay.vue'
import { apiJson } from '@/paskia/fetch'
import { apiJson } from 'paskia'
import { formatDate } from '@/utils/helpers'
import { getDirection } from '@/utils/keynav'
import { useAuthStore } from '@/stores/auth'
+1 -1
View File
@@ -58,7 +58,7 @@
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
import { fetchJson, getUserFriendlyErrorMessage } from '@/paskia/fetch'
import { fetchJson, getUserFriendlyErrorMessage } from 'paskia'
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
import { focusDialogButton } from '@/utils/keynav'
-124
View File
@@ -1,124 +0,0 @@
import { showAuthIframe, AuthCancelledError } from './overlay'
export { AuthCancelledError }
const DEFAULT_TIMEOUT_MS = 1000
export class ApiError extends Error {
constructor(url, response, data) {
super(data?.detail || `Request failed: ${response.status}`)
this.name = 'ApiError'
this.url = url
this.status = response.status
this.statusText = response.statusText
this.data = data
}
}
export class NetworkError extends Error {
constructor(message, originalError = null) {
super(message)
this.name = 'NetworkError'
this.originalError = originalError
}
}
export async function apiFetch(url, options = {}) {
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
fetchOptions.credentials = fetchOptions.credentials || 'include'
while (true) {
let response
try {
response = await fetch(url, {...fetchOptions, signal: timeout && AbortSignal.timeout(timeout)})
} catch (error) {
if (error.name === 'TimeoutError') {
throw new NetworkError('Request timed out', error)
}
if (error.name === 'AbortError') {
throw error
}
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
throw new NetworkError('Unable to connect to server', error)
}
throw new NetworkError(error.message || 'Network error', error)
}
if (response.status === 401 || response.status === 403) {
let data = null
try {
data = await response.clone().json()
} catch {}
if (data.auth?.iframe && window === window.top) {
await showAuthIframe(data.auth.iframe)
continue // Retry the original request after successful auth
}
}
return response
}
}
export async function apiJson(url, options = {}) {
return fetchJson(url, options, apiFetch)
}
export async function fetchJson(url, options = {}, fetchFn = fetch) {
const opt = { ...options }
opt.headers = {
'Accept': 'application/json',
...opt.headers,
}
if (opt.body && typeof opt.body === 'object' && !(opt.body instanceof FormData)) {
opt.headers = {
'Content-Type': 'application/json',
...opt.headers,
}
opt.body = JSON.stringify(opt.body)
}
const response = await fetchFn(url, opt)
const data = await response.json()
if (!response.ok) {
throw new ApiError(url, response, data)
}
return data
}
export function getUserFriendlyErrorMessage(error) {
if (error instanceof NetworkError) return error.message
if (error instanceof ApiError) return error.message
if (error.name === 'TimeoutError') return 'Request timed out'
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
return 'Unable to connect to server'
}
return error.message || 'An error occurred'
}
export function shouldShowErrorToast(error) {
if (error instanceof AuthCancelledError) return false
if (error.name === 'AbortError') return false
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) return false
return true
}
export function createApiCaller(showMessage) {
return async function apiCall(url, options = {}) {
try {
return await apiJson(url, options)
} catch (error) {
if (!shouldShowErrorToast(error)) {
throw error
}
console.error(`API error for ${url}:`, error instanceof ApiError ? { status: error.status, statusText: error.statusText, data: error.data } : error)
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
throw error
}
}
}
export default apiFetch
-166
View File
@@ -1,166 +0,0 @@
const AUTH_IFRAME_ID = 'paskia-iframe'
const STYLES_ID = 'paskia-dialog'
const STYLES_TEXT = `\
body::before {
content: '';
position: fixed;
inset: 0;
z-index: 1099;
background: transparent;
backdrop-filter: blur(0) brightness(1);
-webkit-backdrop-filter: blur(0) brightness(1);
pointer-events: none;
visibility: hidden;
transition: all 0.2s ease-out;
}
body.paskia-backdrop::before {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
visibility: visible;
}
body.paskia-backdrop {
overflow: auto;
}
#${AUTH_IFRAME_ID} {
border: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
color-scheme: auto;
background: transparent;
}
`
let authIframe = null
let authPromise = null
let authResolve = null
let authReject = null
let messageListenerInstalled = false
let backdropHolders = 0
function injectStyles() {
if (document.getElementById(STYLES_ID)) return
const style = document.createElement('style')
style.id = STYLES_ID
style.textContent = STYLES_TEXT
document.head.insertBefore(style, document.head.firstChild)
}
export class AuthCancelledError extends Error {
constructor() {
super('Authentication cancelled')
this.name = 'AuthCancelledError'
}
}
export function holdGlobalBackdrop() {
backdropHolders++
document.body.classList.add('paskia-backdrop')
}
export function releaseGlobalBackdrop() {
backdropHolders = Math.max(0, backdropHolders - 1)
if (backdropHolders === 0) {
document.body.classList.remove('paskia-backdrop')
}
}
export function isAuthIframeOpen() {
return !!document.getElementById(AUTH_IFRAME_ID)
}
export function hideAuthIframe() {
if (authIframe) {
authIframe.remove()
authIframe = null
releaseGlobalBackdrop()
}
}
function handleAuthMessage(event) {
const data = event.data
if (!data?.type) return
switch (data.type) {
case 'auth-success':
hideAuthIframe()
if (authResolve) {
authResolve()
authPromise = null
authResolve = null
authReject = null
}
break
case 'auth-back':
hideAuthIframe()
if (authReject) {
authReject(new AuthCancelledError())
authPromise = null
authResolve = null
authReject = null
}
break
}
}
function ensureMessageListener() {
if (messageListenerInstalled) return
if (typeof window !== 'undefined') {
window.addEventListener('message', handleAuthMessage)
messageListenerInstalled = true
}
}
export function showAuthIframe(iframeUrl, title = 'Authentication') {
injectStyles()
ensureMessageListener()
if (authPromise) return authPromise
if (document.getElementById(AUTH_IFRAME_ID)) {
authPromise = new Promise((resolve, reject) => {
authResolve = resolve
authReject = reject
})
return authPromise
}
authPromise = new Promise((resolve, reject) => {
authResolve = resolve
authReject = reject
})
hideAuthIframe()
holdGlobalBackdrop()
authIframe = document.createElement('iframe')
authIframe.id = AUTH_IFRAME_ID
authIframe.title = title
authIframe.src = iframeUrl
document.body.appendChild(authIframe)
return authPromise
}
export function createAuthIframe(iframeUrl, title = 'Authentication') {
injectStyles()
const existing = document.getElementById(AUTH_IFRAME_ID)
if (existing) existing.remove()
const iframe = document.createElement('iframe')
iframe.id = AUTH_IFRAME_ID
iframe.title = title
iframe.src = iframeUrl
document.body.appendChild(iframe)
return iframe
}
export function removeAuthIframe() {
const iframe = document.getElementById(AUTH_IFRAME_ID)
if (iframe) iframe.remove()
}
-63
View File
@@ -1,63 +0,0 @@
import { apiJson } from './fetch.js'
const POLL_INTERVAL = 60 * 1000
const IDLE_TIMEOUT = 5 * 60 * 1000
export class SessionValidator {
constructor(userUuidGetter, onSessionLost) {
this.userUuidGetter = userUuidGetter
this.onSessionLost = onSessionLost
this.pollTimer = null
this.idleTimer = null
this.active = false
this.resetIdleTimer = this.resetIdleTimer.bind(this)
}
resetIdleTimer() {
if (this.idleTimer) clearTimeout(this.idleTimer)
if (!this.active) this.startPolling()
this.idleTimer = setTimeout(() => this.stopPolling(), IDLE_TIMEOUT)
}
async validate() {
try {
const data = await apiJson('/auth/api/validate', { method: 'POST' })
const newUuid = data.ctx?.user?.uuid
if (newUuid !== this.userUuidGetter()) {
window.location.reload()
}
} catch (error) {
if (error.name !== 'NetworkError') {
this.stopPolling()
this.onSessionLost(error)
}
}
}
startPolling() {
if (this.active) return
this.active = true
this.pollTimer = setInterval(() => this.validate(), POLL_INTERVAL)
}
stopPolling() {
this.active = false
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
}
start() {
window.addEventListener('pointermove', this.resetIdleTimer)
window.addEventListener('pointerdown', this.resetIdleTimer)
this.resetIdleTimer()
}
stop() {
window.removeEventListener('pointermove', this.resetIdleTimer)
window.removeEventListener('pointerdown', this.resetIdleTimer)
if (this.idleTimer) clearTimeout(this.idleTimer)
this.stopPolling()
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { register, authenticate } from '@/utils/passkey'
import { getSettings } from '@/utils/settings'
import { apiJson } from '@/paskia/fetch'
import { apiJson } from 'paskia'
export const useAuthStore = defineStore('auth', {
state: () => ({
+1 -1
View File
@@ -1,7 +1,7 @@
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
import aWebSocket from '@/utils/awaitable-websocket'
import { getSettings } from '@/utils/settings'
import { showAuthIframe } from '@/paskia/overlay'
import { showAuthIframe } from 'paskia'
// Generic path normalizer: if an auth_host is configured and differs from current
// host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is.