Refactor to separate paskia lib functionality generally useful for various apps.
This commit is contained in:
+14
-25
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,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: () => ({
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user