diff --git a/frontend/auth/App.vue b/frontend/auth/App.vue index 08dd74d..de01d77 100644 --- a/frontend/auth/App.vue +++ b/frontend/auth/App.vue @@ -13,10 +13,8 @@ +``` + +## Features + +### Session Validation + +Refresh session and track its validity with automatic polling. Pauses on lack of user activity to avoid useless traffic and to allow session expiry even when the page is left open but idle. This monitors that the same account stays logged in but doesn't do any permission checks. + +```js +import { SessionValidator } from 'paskia' + +const validator = new SessionValidator( + () => currentUser?.uuid, // getter for current user ID that we track + (error) => handleSessionLost(error) // callback when session is lost +) + +validator.start() // call at your app startup/login +validator.stop() // stop the system (optional) +``` + +### API Fetch Utilities + +Enhanced fetch functions with automatic error handling and authentication retry: + +```js +import { apiJson, apiFetch } from 'paskia' + +// JSON API calls with automatic auth handling +const data = await apiJson('/api/endpoint', { method: 'POST', body: { key: 'value' } }) + +// Raw fetch with auth handling +const response = await apiFetch('/api/endpoint') +``` + +When a 401/403 response includes an auth iframe URL, the request automatically pauses, displays the authentication UI, and retries upon success. In case this is not needed, use standard `fetch` or our `fetchJson`. + +The JSON variants set headers automatically, with body and response in JSON. + +### Authentication Overlay + +Normally you use apiJson/apiFetch and they handle this automatically. If you need to wire it yourself, on a 401/403 response that includes `auth.iframe`, call `showAuthIframe(...)` and then retry the original request. + +The backend returns 401/403 responses with the correct URL for proper user feedback. Alternatively you may use `/auth/restricted/#mode=login`, `mode=reauth` or `mode=forbidden` to trigger the UX flow you need. + +```js +import { showAuthIframe, AuthCancelledError } from 'paskia' + +const response = await fetch('/api/protected') +if (response.status === 401 || response.status === 403) { + const data = await response.json() + if (data.auth?.iframe) { + await showAuthIframe(data.auth.iframe) // Raises AuthCancelledError if the user cancels + } +} +``` + +This resolves after the user authenticates (possibly with another account than previously), and you should usually retry the original API request. Note that successful authentication doesn't guarantee that the user still has rights to what originally failed. + +### Shared Blur Backdrop + +The authentication dialog displays with a blur backdrop (z-index 1099). The auth iframe uses z-index 9999. Your app dialogs should use z-index 1100–9998 to appear above the backdrop but below authentication. + +The backdrop is also reusable/refcounted, so you can keep consistent visuals for your own dialogs: + +```js +import { holdGlobalBackdrop, releaseGlobalBackdrop } from 'paskia' + +holdGlobalBackdrop() +try { + await your.own.dialog() +} finally { + releaseGlobalBackdrop() +} +``` + +The backdrop only disappears after all holders have released it. + +## Error Handling + +### AuthCancelledError (apiFetch, apiJson, showAuthIframe) + +If the user clicks Back in the authentication dialog, refusing to authenticate, `AuthCancelledError` is risen (as a response to postMessage from the iframe). The dialog closes as expected and it is up to the app how to continue from there. + +- Do nothing if the app can continue despite the failed operation (no UI notification needed) +- Display a simple Access Denied page with suggestion/button to reload the page to try again + +Do not retry automatically. + +### UI feedback + +A set of small utilities are available for determining whether the user needs a notification and to format the error message. + +```js +import { getUserFriendlyErrorMessage, shouldShowErrorToast } from 'paskia' + +try { + await apiJson('/api/action') +} catch (e) { + if (shouldShowErrorToast(e)) { + your.message.display(getUserFriendlyErrorMessage(e)) + } +} +``` diff --git a/paskia-js/package.json b/paskia-js/package.json new file mode 100644 index 0000000..4ef5181 --- /dev/null +++ b/paskia-js/package.json @@ -0,0 +1,32 @@ +{ + "name": "paskia", + "version": "0.1.2", + "description": "Paskia authentication utilities for JavaScript", + "type": "module", + "main": "./dist/paskia.js", + "types": "./dist/paskia.d.ts", + "exports": { + ".": { + "types": "./dist/paskia.d.ts", + "import": "./dist/paskia.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "vite build", + "prepublishOnly": "npm run build" + }, + "devDependencies": { + "typescript": "~5.8.0", + "vite": "^7.3.1", + "vite-plugin-dts": "^4.5.4" + }, + "keywords": [ + "auth", + "authentication", + "paskia" + ], + "license": "Unlicense" +} diff --git a/paskia-js/src/fetch.ts b/paskia-js/src/fetch.ts new file mode 100644 index 0000000..adc1056 --- /dev/null +++ b/paskia-js/src/fetch.ts @@ -0,0 +1,146 @@ +import { showAuthIframe, AuthCancelledError } from './overlay' + +export { AuthCancelledError } + +const DEFAULT_TIMEOUT_MS = 1000 + +export interface ApiFetchOptions extends RequestInit { + timeout?: number +} + +export interface FetchJsonOptions extends Omit { + timeout?: number + body?: BodyInit | Record | null +} + +export class ApiError extends Error { + readonly url: string + readonly status: number + readonly statusText: string + readonly data: unknown + + constructor(url: string, response: Response, data: unknown) { + super((data as { detail?: string })?.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 { + readonly originalError: Error | null + + constructor(message: string, originalError: Error | null = null) { + super(message) + this.name = 'NetworkError' + this.originalError = originalError + } +} + +export async function apiFetch(url: string, options: ApiFetchOptions = {}): Promise { + const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options + fetchOptions.credentials = fetchOptions.credentials || 'include' + + while (true) { + let response: Response + try { + response = await fetch(url, {...fetchOptions, signal: timeout ? AbortSignal.timeout(timeout) : undefined}) + } catch (error) { + const err = error as Error + if (err.name === 'TimeoutError') { + throw new NetworkError('Request timed out', err) + } + if (err.name === 'AbortError') { + throw error + } + if (err.name === 'TypeError' && err.message === 'Failed to fetch') { + throw new NetworkError('Unable to connect to server', err) + } + throw new NetworkError(err.message || 'Network error', err) + } + + if (response.status === 401 || response.status === 403) { + let data: { auth?: { iframe?: string } } | null = 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 + } +} + +type FetchFn = (url: string, options?: RequestInit) => Promise + +export async function apiJson(url: string, options: FetchJsonOptions = {}): Promise { + return fetchJson(url, options, apiFetch) +} + +export async function fetchJson(url: string, options: FetchJsonOptions = {}, fetchFn: FetchFn = fetch): Promise { + const headers: Record = { + 'Accept': 'application/json', + ...(options.headers as Record), + } + + let body: BodyInit | undefined + if (options.body && typeof options.body === 'object' && !(options.body instanceof FormData)) { + headers['Content-Type'] = 'application/json' + body = JSON.stringify(options.body) + } else { + body = options.body as BodyInit + } + + const opt: RequestInit = { ...options, headers, body } + + const response = await fetchFn(url, opt) + const data = await response.json() as T + + if (!response.ok) { + throw new ApiError(url, response, data) + } + + return data +} + +export function getUserFriendlyErrorMessage(error: Error): string { + 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: Error): boolean { + 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 +} + +type ShowMessageFn = (message: string, type: string, duration: number) => void + +export function createApiCaller(showMessage: ShowMessageFn) { + return async function apiCall(url: string, options: FetchJsonOptions = {}): Promise { + try { + return await apiJson(url, options) + } catch (error) { + if (!shouldShowErrorToast(error as Error)) { + throw error + } + const err = error as Error + console.error(`API error for ${url}:`, err instanceof ApiError ? { status: err.status, statusText: err.statusText, data: err.data } : err) + showMessage(getUserFriendlyErrorMessage(err), 'error', 4000) + throw error + } + } +} + +export default apiFetch diff --git a/paskia-js/src/index.ts b/paskia-js/src/index.ts new file mode 100644 index 0000000..2b66002 --- /dev/null +++ b/paskia-js/src/index.ts @@ -0,0 +1,25 @@ +export { + ApiError, + NetworkError, + AuthCancelledError, + apiFetch, + apiJson, + fetchJson, + getUserFriendlyErrorMessage, + shouldShowErrorToast, + createApiCaller, +} from './fetch' + +export type { ApiFetchOptions, FetchJsonOptions } from './fetch' + +export { + holdGlobalBackdrop, + releaseGlobalBackdrop, + isAuthIframeOpen, + hideAuthIframe, + showAuthIframe, + createAuthIframe, + removeAuthIframe, +} from './overlay' + +export { SessionValidator } from './validate' diff --git a/frontend/src/paskia/overlay.js b/paskia-js/src/overlay.ts similarity index 80% rename from frontend/src/paskia/overlay.js rename to paskia-js/src/overlay.ts index bc3190b..3890caf 100644 --- a/frontend/src/paskia/overlay.js +++ b/paskia-js/src/overlay.ts @@ -34,14 +34,14 @@ body.paskia-backdrop { } ` -let authIframe = null -let authPromise = null -let authResolve = null -let authReject = null +let authIframe: HTMLIFrameElement | null = null +let authPromise: Promise | null = null +let authResolve: (() => void) | null = null +let authReject: ((error: Error) => void) | null = null let messageListenerInstalled = false let backdropHolders = 0 -function injectStyles() { +function injectStyles(): void { if (document.getElementById(STYLES_ID)) return const style = document.createElement('style') style.id = STYLES_ID @@ -56,23 +56,23 @@ export class AuthCancelledError extends Error { } } -export function holdGlobalBackdrop() { +export function holdGlobalBackdrop(): void { backdropHolders++ document.body.classList.add('paskia-backdrop') } -export function releaseGlobalBackdrop() { +export function releaseGlobalBackdrop(): void { backdropHolders = Math.max(0, backdropHolders - 1) if (backdropHolders === 0) { document.body.classList.remove('paskia-backdrop') } } -export function isAuthIframeOpen() { +export function isAuthIframeOpen(): boolean { return !!document.getElementById(AUTH_IFRAME_ID) } -export function hideAuthIframe() { +export function hideAuthIframe(): void { if (authIframe) { authIframe.remove() authIframe = null @@ -80,8 +80,8 @@ export function hideAuthIframe() { } } -function handleAuthMessage(event) { - const data = event.data +function handleAuthMessage(event: MessageEvent): void { + const data = event.data as { type?: string } if (!data?.type) return switch (data.type) { @@ -107,7 +107,7 @@ function handleAuthMessage(event) { } } -function ensureMessageListener() { +function ensureMessageListener(): void { if (messageListenerInstalled) return if (typeof window !== 'undefined') { window.addEventListener('message', handleAuthMessage) @@ -115,7 +115,7 @@ function ensureMessageListener() { } } -export function showAuthIframe(iframeUrl, title = 'Authentication') { +export function showAuthIframe(iframeUrl: string, title = 'Authentication'): Promise { injectStyles() ensureMessageListener() @@ -146,7 +146,7 @@ export function showAuthIframe(iframeUrl, title = 'Authentication') { return authPromise } -export function createAuthIframe(iframeUrl, title = 'Authentication') { +export function createAuthIframe(iframeUrl: string, title = 'Authentication'): HTMLIFrameElement { injectStyles() const existing = document.getElementById(AUTH_IFRAME_ID) if (existing) existing.remove() @@ -160,7 +160,7 @@ export function createAuthIframe(iframeUrl, title = 'Authentication') { return iframe } -export function removeAuthIframe() { +export function removeAuthIframe(): void { const iframe = document.getElementById(AUTH_IFRAME_ID) if (iframe) iframe.remove() } diff --git a/frontend/src/paskia/validate.js b/paskia-js/src/validate.ts similarity index 62% rename from frontend/src/paskia/validate.js rename to paskia-js/src/validate.ts index 11d6c81..042b46c 100644 --- a/frontend/src/paskia/validate.js +++ b/paskia-js/src/validate.ts @@ -1,46 +1,49 @@ -import { apiJson } from './fetch.js' +import { apiJson } from './fetch' const POLL_INTERVAL = 60 * 1000 const IDLE_TIMEOUT = 5 * 60 * 1000 export class SessionValidator { - constructor(userUuidGetter, onSessionLost) { + private userUuidGetter: () => string | undefined + private onSessionLost: (error: Error) => void + private pollTimer: ReturnType | null = null + private idleTimer: ReturnType | null = null + private active = false + + constructor(userUuidGetter: () => string | undefined, onSessionLost: (error: Error) => void) { this.userUuidGetter = userUuidGetter this.onSessionLost = onSessionLost - this.pollTimer = null - this.idleTimer = null - this.active = false this.resetIdleTimer = this.resetIdleTimer.bind(this) } - resetIdleTimer() { + resetIdleTimer(): void { if (this.idleTimer) clearTimeout(this.idleTimer) if (!this.active) this.startPolling() this.idleTimer = setTimeout(() => this.stopPolling(), IDLE_TIMEOUT) } - async validate() { + async validate(): Promise { try { - const data = await apiJson('/auth/api/validate', { method: 'POST' }) + const data = await apiJson<{ ctx?: { user?: { uuid?: string } } }>('/auth/api/validate', { method: 'POST' }) const newUuid = data.ctx?.user?.uuid if (newUuid !== this.userUuidGetter()) { window.location.reload() } } catch (error) { - if (error.name !== 'NetworkError') { + if ((error as Error).name !== 'NetworkError') { this.stopPolling() - this.onSessionLost(error) + this.onSessionLost(error as Error) } } } - startPolling() { + startPolling(): void { if (this.active) return this.active = true this.pollTimer = setInterval(() => this.validate(), POLL_INTERVAL) } - stopPolling() { + stopPolling(): void { this.active = false if (this.pollTimer) { clearInterval(this.pollTimer) @@ -48,13 +51,13 @@ export class SessionValidator { } } - start() { + start(): void { window.addEventListener('pointermove', this.resetIdleTimer) window.addEventListener('pointerdown', this.resetIdleTimer) this.resetIdleTimer() } - stop() { + stop(): void { window.removeEventListener('pointermove', this.resetIdleTimer) window.removeEventListener('pointerdown', this.resetIdleTimer) if (this.idleTimer) clearTimeout(this.idleTimer) diff --git a/paskia-js/tsconfig.json b/paskia-js/tsconfig.json new file mode 100644 index 0000000..6a7a7c6 --- /dev/null +++ b/paskia-js/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "declaration": true, + "declarationDir": "./dist", + "outDir": "./dist", + "rootDir": "./src", + "lib": ["ES2020", "DOM"], + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/paskia-js/vite.config.js b/paskia-js/vite.config.js new file mode 100644 index 0000000..d061f6c --- /dev/null +++ b/paskia-js/vite.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import { resolve } from 'path' +import dts from 'vite-plugin-dts' + +export default defineConfig({ + plugins: [dts({ rollupTypes: true })], + build: { + lib: { + entry: resolve(__dirname, 'src/index.ts'), + fileName: 'paskia', + formats: ['es'], + }, + }, +})