Refactor to separate paskia lib functionality generally useful for various apps.

This commit is contained in:
Leo Vasanko
2026-01-29 16:55:46 +00:00
parent 760b109eef
commit d8743d9f90
15 changed files with 381 additions and 504 deletions
+14 -25
View File
@@ -13,8 +13,10 @@
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { apiJson, getAuthIframeUrl } from '@/utils/api'
import { useSessionValidation } from '@/utils/session'
import { apiJson } from '@/paskia/fetch'
import { getAuthIframeUrl } from '@/utils/api'
import { SessionValidator } from '@/paskia/validate'
import { createAuthIframe, removeAuthIframe } from '@/paskia/overlay'
import StatusMessage from '@/components/StatusMessage.vue'
import ProfileView from '@/components/ProfileView.vue'
import HostProfileView from '@/components/HostProfileView.vue'
@@ -48,14 +50,17 @@ const isHostMode = computed(() => {
return currentHost !== configuredHost
})
const userUuid = computed(() => store.userInfo?.ctx.user.uuid)
let authIframe = null
function terminateSession() {
store.userInfo = null
viewState.value = 'terminal'
}
useSessionValidation(userUuid, terminateSession)
const userUuidGetter = () => store.userInfo?.ctx.user.uuid
const sessionValidator = new SessionValidator(userUuidGetter, terminateSession)
onMounted(() => sessionValidator.start())
onUnmounted(() => sessionValidator.stop())
async function loadUserInfo() {
try {
@@ -69,27 +74,11 @@ async function loadUserInfo() {
}
async function showAuthIframe() {
// Remove existing iframe if any
hideAuthIframe()
// Create new iframe for authentication using src URL
const url = await getAuthIframeUrl('login')
authIframe = document.createElement('iframe')
authIframe.id = 'auth-iframe'
authIframe.title = 'Authentication'
authIframe.allow = 'publickey-credentials-get; publickey-credentials-create'
authIframe.src = url
document.body.appendChild(authIframe)
createAuthIframe(url)
loadingMessage.value = 'Authentication required...'
}
function hideAuthIframe() {
if (authIframe) {
authIframe.remove()
authIframe = null
}
}
function handleAuthMessage(event) {
const data = event.data
if (!data?.type) return
@@ -97,7 +86,7 @@ function handleAuthMessage(event) {
switch (data.type) {
case 'auth-success':
// Authentication successful - reload user info
hideAuthIframe()
removeAuthIframe()
viewState.value = 'loading'
loadingMessage.value = 'Loading user profile...'
loadUserInfo()
@@ -119,13 +108,13 @@ function handleAuthMessage(event) {
case 'auth-back':
// User clicked Back - show terminal state
hideAuthIframe()
removeAuthIframe()
terminateSession()
break
case 'auth-close-request':
// Legacy support - treat as back
hideAuthIframe()
removeAuthIframe()
break
}
}
@@ -158,7 +147,7 @@ onMounted(async () => {
onUnmounted(() => {
window.removeEventListener('message', handleAuthMessage)
hideAuthIframe()
removeAuthIframe()
})
</script>
+7 -4
View File
@@ -12,8 +12,8 @@ import AdminUserDetail from '@/admin/AdminUserDetail.vue'
import AdminDialogs from '@/admin/AdminDialogs.vue'
import { useAuthStore } from '@/stores/auth'
import { adminUiPath, makeUiHref } from '@/utils/settings'
import { apiJson } from '@/utils/api'
import { useSessionValidation } from '@/utils/session'
import { apiJson } from '@/paskia/fetch'
import { SessionValidator } from '@/paskia/validate'
import { getDirection } from '@/utils/keynav'
import { goBack } from '@/utils/helpers'
@@ -167,8 +167,11 @@ function onSessionLost(e) {
}
}
const userUuid = computed(() => info.value?.ctx.user.uuid)
useSessionValidation(userUuid, onSessionLost)
const userUuidGetter = () => info.value?.ctx.user.uuid
const sessionValidator = new SessionValidator(userUuidGetter, onSessionLost)
onMounted(() => sessionValidator.start())
onUnmounted(() => sessionValidator.stop())
async function load() {
loading.value = true
+1 -1
View File
@@ -59,7 +59,7 @@
import { computed, onMounted, reactive, ref } from 'vue'
import passkey from '@/utils/passkey'
import { getSettings, uiBasePath } from '@/utils/settings'
import { apiJson, ApiError, getUserFriendlyErrorMessage } from '@/utils/api'
import { apiJson, ApiError, getUserFriendlyErrorMessage } from '@/paskia/fetch'
const status = reactive({
show: false,
+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 '@/utils/api'
import { apiJson } from '@/paskia/fetch'
import { getDirection, navigateButtonRow, focusPreferred, focusAtIndex } from '@/utils/keynav'
const props = defineProps({
-36
View File
@@ -731,42 +731,6 @@ th {
}
}
/* Global backdrop controlled by api.js ref-counting */
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.has-backdrop::before {
-webkit-backdrop-filter: blur(.2rem) brightness(0.5);
backdrop-filter: blur(.2rem) brightness(0.5);
visibility: visible;
}
body.has-backdrop {
overflow: auto;
}
#auth-iframe {
border: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
color-scheme: auto;
background: transparent;
}
.slot-machine {
padding: 0.875rem 1rem;
background: var(--color-surface-hover, rgba(0, 0, 0, 0.03));
+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 '@/utils/api'
import { apiJson } from '@/paskia/fetch'
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 '@/utils/api'
import { apiJson } from '@/paskia/fetch'
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 '@/utils/api'
import { fetchJson, getUserFriendlyErrorMessage } from '@/paskia/fetch'
import RemoteAuthRequest from '@/components/RemoteAuthRequest.vue'
import { focusDialogButton } from '@/utils/keynav'
+124
View File
@@ -0,0 +1,124 @@
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
@@ -0,0 +1,166 @@
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
@@ -0,0 +1,63 @@
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 '@/utils/api'
import { apiJson } from '@/paskia/fetch'
export const useAuthStore = defineStore('auth', {
state: () => ({
-373
View File
@@ -1,77 +1,3 @@
/**
* API fetch wrapper that handles authentication errors with iframe-based re-authentication.
*
* When a 401 or 403 response is received with an `auth` object containing `iframe` URL,
* this wrapper shows an authentication iframe and retries the original request after
* successful authentication.
*/
/** Default timeout for API requests in milliseconds */
const DEFAULT_TIMEOUT_MS = 1000
/**
* Custom error class for API errors with full response context.
*/
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
}
}
/**
* Custom error class for network/timeout errors.
*/
export class NetworkError extends Error {
constructor(message, originalError = null) {
super(message)
this.name = 'NetworkError'
this.originalError = originalError
}
}
/**
* Error thrown when user cancels authentication.
*/
export class AuthCancelledError extends Error {
constructor() {
super('Authentication cancelled')
this.name = 'AuthCancelledError'
}
}
let authIframe = null
let authPromise = null
let authResolve = null
let authReject = null
// Global backdrop ref-count (works independently of Pinia store)
let backdropHolders = 0
/**
* Hold global backdrop (increment ref-count).
* Multiple callers can hold the backdrop; it only hides when all release.
*/
export function holdGlobalBackdrop() {
backdropHolders++
document.body.classList.add('has-backdrop')
}
/**
* Release global backdrop (decrement ref-count).
* Backdrop hides only when ref-count reaches zero.
*/
export function releaseGlobalBackdrop() {
backdropHolders = Math.max(0, backdropHolders - 1)
if (backdropHolders === 0) {
document.body.classList.remove('has-backdrop')
}
}
// Cache for auth iframe URL by mode
const authIframeUrlCache = {}
@@ -104,302 +30,3 @@ export async function getAuthIframeUrl(mode = 'login') {
}
throw new Error('Unable to fetch auth iframe URL')
}
/**
* Check if an auth iframe is already open (from any source).
* @returns {boolean}
*/
export function isAuthIframeOpen() {
return !!document.getElementById('auth-iframe')
}
/**
* Show the authentication iframe and return a promise that resolves on success.
* If an auth iframe is already open (from any source), hooks into its completion.
* Uses global backdrop system to avoid flicker between auth and caller's UI.
* @param {string} iframeUrl - The URL for the iframe src
* @returns {Promise<void>}
* @throws {AuthCancelledError} - If authentication is cancelled by user
*/
export function showAuthIframe(iframeUrl) {
// If we already have a promise (from us), return it
if (authPromise) return authPromise
// If there's already an iframe in the DOM (from App.vue or elsewhere),
// create a promise that hooks into the message handler
if (document.getElementById('auth-iframe')) {
authPromise = new Promise((resolve, reject) => {
authResolve = resolve
authReject = reject
})
return authPromise
}
authPromise = new Promise((resolve, reject) => {
authResolve = resolve
authReject = reject
})
// Remove existing iframe if any
hideAuthIframe()
// Hold global backdrop for auth iframe
holdGlobalBackdrop()
// Create new iframe for authentication using src URL
authIframe = document.createElement('iframe')
authIframe.id = 'auth-iframe'
authIframe.title = 'Authentication'
authIframe.allow = 'publickey-credentials-get; publickey-credentials-create'
authIframe.src = iframeUrl
document.body.appendChild(authIframe)
return authPromise
}
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':
case 'auth-close-request':
hideAuthIframe()
if (authReject) {
authReject(new AuthCancelledError())
authPromise = null
authResolve = null
authReject = null
}
break
case 'auth-error':
// Keep iframe open for retry, but if cancelled, treat as back
if (data.cancelled && authReject) {
hideAuthIframe()
authReject(new AuthCancelledError())
authPromise = null
authResolve = null
authReject = null
}
break
}
}
// Install global message listener
if (typeof window !== 'undefined') {
window.addEventListener('message', handleAuthMessage)
}
/**
* Fetch wrapper that handles auth errors with iframe-based re-authentication.
* Loops until successful or user cancels authentication.
*
* @param {string|URL} url - The URL to fetch
* @param {RequestInit} [options] - Fetch options
* @param {number} [options.timeout] - Timeout in ms (default: 10000, use 0 to disable)
* @returns {Promise<Response>} - The fetch response
* @throws {AuthCancelledError} - If authentication is cancelled by user
* @throws {NetworkError} - If network error or timeout occurs
*/
export async function apiFetch(url, options = {}) {
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
// Ensure credentials are included for cookie-based auth
fetchOptions.credentials = fetchOptions.credentials || 'include'
while (true) {
let response
try {
response = await fetch(url, {...fetchOptions, signal: timeout && AbortSignal.timeout(timeout)})
} catch (error) {
// Handle network errors and timeouts
if (error.name === 'TimeoutError') {
throw new NetworkError('Request timed out', error)
}
if (error.name === 'AbortError') {
// Re-throw abort errors as-is (user-initiated cancellation)
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)
}
// Check for auth errors (401/403)
if (response.status === 401 || response.status === 403) {
// Try to parse the response to get the iframe URL
let authInfo = null
try {
const data = await response.clone().json()
authInfo = data.auth
} catch {
// If we can't parse JSON, no iframe available
}
// Authenticate via iframe (only in top-level window)
if (authInfo?.iframe && window === window.top) {
// Show auth iframe (or wait for existing one) and retry on success
// showAuthIframe returns existing promise if iframe is already open
await showAuthIframe(authInfo.iframe)
continue // Retry the original request
}
}
return response
}
}
/**
* Convenience method for JSON API calls.
* Automatically sets Accept and Content-Type headers.
* Returns parsed JSON directly if response is ok, throws ApiError otherwise.
*
* @param {string|URL} url - The URL to fetch
* @param {RequestInit} [options] - Fetch options
* @returns {Promise<any>} - Parsed JSON response
* @throws {ApiError} - If response is not ok
* @throws {NetworkError} - If network error or timeout occurs
* @throws {AuthCancelledError} - If authentication is cancelled by user
*/
export async function apiJson(url, options = {}) {
const fetchOptions = { ...options }
// Set default headers, allowing caller overrides
fetchOptions.headers = {
'Accept': 'application/json',
...fetchOptions.headers,
}
// Set Content-Type for requests with JSON body
if (fetchOptions.body && typeof fetchOptions.body === 'object' && !(fetchOptions.body instanceof FormData)) {
fetchOptions.headers = {
'Content-Type': 'application/json',
...fetchOptions.headers,
}
fetchOptions.body = JSON.stringify(fetchOptions.body)
}
const response = await apiFetch(url, fetchOptions)
const data = await response.json()
if (!response.ok) {
throw new ApiError(url, response, data)
}
return data
}
/**
* Simple JSON fetch without auto-auth iframe handling.
* Use this in contexts where showing an auth iframe would be inappropriate
* (e.g., inside the auth iframe itself).
*
* @param {string|URL} url - The URL to fetch
* @param {RequestInit} [options] - Fetch options
* @returns {Promise<any>} - Parsed JSON response
* @throws {ApiError} - If response is not ok
*/
export async function fetchJson(url, options = {}) {
const fetchOptions = {
...options,
headers: {
'Accept': 'application/json',
...options.headers,
},
}
const response = await fetch(url, fetchOptions)
const data = await response.json()
if (!response.ok) {
throw new ApiError(url, response, data)
}
return data
}
/**
* Convert an error to a user-friendly message.
* @param {Error} error - The error to convert
* @returns {string} - User-friendly error message
*/
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'
}
/**
* Check if an error should show a toast to the user.
* @param {Error} error - The error to check
* @returns {boolean} - Whether to show a toast
*/
export function shouldShowErrorToast(error) {
// Don't show toast for user cancellations
if (error instanceof AuthCancelledError) return false
if (error.name === 'AbortError') return false
// Don't show toast for 401/403 errors - the auth iframe will handle these
if (error instanceof ApiError && (error.status === 401 || error.status === 403)) return false
return true
}
/**
* Create an API caller with error handling (toast + console.error).
* Wraps apiJson calls with consistent error handling for apps.
*
* @param {Function} showMessage - Function to show toast messages: (message, type, duration) => void
* @returns {Function} - Wrapped apiJson that handles errors
*/
export function createApiCaller(showMessage) {
/**
* @param {string|URL} url - The URL to fetch
* @param {RequestInit} [options] - Fetch options
* @returns {Promise<any>} - Parsed JSON response, or undefined on error
*/
return async function apiCall(url, options = {}) {
try {
return await apiJson(url, options)
} catch (error) {
if (!shouldShowErrorToast(error)) {
throw error
}
// Log full error details
console.error(`API error for ${url}:`, error instanceof ApiError ? { status: error.status, statusText: error.statusText, data: error.data } : error)
// Show user-friendly toast
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
throw error
}
}
}
export default apiFetch
+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 '@/utils/api'
import { showAuthIframe } from '@/paskia/overlay'
// 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.
-59
View File
@@ -1,59 +0,0 @@
import { onMounted, onUnmounted } from 'vue'
import { apiJson } from './api'
const POLL_INTERVAL = 60 * 1000
const IDLE_TIMEOUT = 5 * 60 * 1000
export function useSessionValidation(userUuid, onSessionLost) {
let pollTimer = null
let idleTimer = null
let active = false
function resetIdleTimer() {
if (idleTimer) clearTimeout(idleTimer)
if (!active) startPolling()
idleTimer = setTimeout(stopPolling, IDLE_TIMEOUT)
}
async function validate() {
try {
const data = await apiJson('/auth/api/validate', { method: 'POST' })
const newUuid = data.ctx?.user?.uuid
if (newUuid !== userUuid.value) {
window.location.reload()
}
} catch (error) {
if (error.name !== 'NetworkError') {
stopPolling()
onSessionLost(error)
}
}
}
function startPolling() {
if (active) return
active = true
pollTimer = setInterval(validate, POLL_INTERVAL)
}
function stopPolling() {
active = false
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
onMounted(() => {
window.addEventListener('pointermove', resetIdleTimer)
window.addEventListener('pointerdown', resetIdleTimer)
resetIdleTimer()
})
onUnmounted(() => {
window.removeEventListener('pointermove', resetIdleTimer)
window.removeEventListener('pointerdown', resetIdleTimer)
if (idleTimer) clearTimeout(idleTimer)
stopPolling()
})
}