- sessionKey.ts: one 32-byte base64url key per form mount, sent only in headers - api/files.ts: FileRoutes over the record and child file routes, XHR upload with progress, 401 refresh and retry - FileuploadField and FileCaptionModal per UI-SPEC section 3: dropzone, image grid, rows, per-item states, client pre-checks, reorder, protected previews - FormView provides FORM_SESSION, counts pending changes as dirty and sends X-Session-Key on create and update - fileupload lang keys in en and pl, admin-spa docs note, deferred smoke test, rebuilt dist
111 lines
3.4 KiB
TypeScript
111 lines
3.4 KiB
TypeScript
// The only HTTP client of the SPA: openapi-fetch typed by the generated
|
|
// paths (D-15). Every request carries X-Requested-With (the CSRF header the
|
|
// admin API requires on state-changing cookie requests, D-19) and same-origin
|
|
// credentials; the JWT lives in an HttpOnly cookie the SPA never reads.
|
|
//
|
|
// A 401 on any call other than login and refresh starts one shared refresh
|
|
// (concurrent 401s wait for the same one); on success the original request is
|
|
// replayed once from a clone taken before it was sent. A failed refresh or a
|
|
// second 401 reports the session as gone.
|
|
import createClient, { type Middleware } from 'openapi-fetch'
|
|
import type { paths } from './schema'
|
|
import { runtime } from '../app/runtime'
|
|
|
|
export const REQUESTED_WITH = 'XMLHttpRequest'
|
|
|
|
type UnauthorizedHandler = () => void
|
|
type RefreshedHandler = (expiresIn: number | null) => void
|
|
|
|
let unauthorizedHandler: UnauthorizedHandler | null = null
|
|
let refreshedHandler: RefreshedHandler | null = null
|
|
let inflight: Promise<boolean> | null = null
|
|
const replays = new WeakMap<Request, Request>()
|
|
|
|
/** Registers what happens when the session cannot be recovered after a 401. */
|
|
export function onUnauthorized(handler: UnauthorizedHandler | null): void {
|
|
unauthorizedHandler = handler
|
|
}
|
|
|
|
/**
|
|
* Reports a session that could not be recovered, for requests that do not
|
|
* go through the typed client (the upload XMLHttpRequest).
|
|
*/
|
|
export function reportUnauthorized(): void {
|
|
unauthorizedHandler?.()
|
|
}
|
|
|
|
/** Registers a listener for successful refreshes (the new access lifetime). */
|
|
export function onRefreshed(handler: RefreshedHandler | null): void {
|
|
refreshedHandler = handler
|
|
}
|
|
|
|
function pathOf(request: Request): string {
|
|
return new URL(request.url, 'http://local').pathname
|
|
}
|
|
|
|
function isAuthEndpoint(request: Request): boolean {
|
|
const path = pathOf(request)
|
|
return path.endsWith('/auth/login') || path.endsWith('/auth/refresh')
|
|
}
|
|
|
|
/**
|
|
* Refreshes the cookie session. Concurrent callers share one request; the
|
|
* promise resolves to whether the server issued a new session.
|
|
*/
|
|
export function refreshSession(): Promise<boolean> {
|
|
if (inflight) {
|
|
return inflight
|
|
}
|
|
inflight = (async () => {
|
|
try {
|
|
const { data, response } = await api.POST('/auth/refresh')
|
|
if (!response.ok || !data) {
|
|
return false
|
|
}
|
|
const expiresIn = data.data.expires_in
|
|
refreshedHandler?.(typeof expiresIn === 'number' ? expiresIn : null)
|
|
return true
|
|
} catch {
|
|
return false
|
|
} finally {
|
|
inflight = null
|
|
}
|
|
})()
|
|
return inflight
|
|
}
|
|
|
|
export const transport: Middleware = {
|
|
onRequest({ request }) {
|
|
request.headers.set('X-Requested-With', REQUESTED_WITH)
|
|
if (!isAuthEndpoint(request)) {
|
|
replays.set(request, request.clone())
|
|
}
|
|
return request
|
|
},
|
|
async onResponse({ request, response }) {
|
|
if (response.status !== 401 || isAuthEndpoint(request)) {
|
|
return response
|
|
}
|
|
const replay = replays.get(request)
|
|
replays.delete(request)
|
|
if (!replay || !(await refreshSession())) {
|
|
unauthorizedHandler?.()
|
|
return response
|
|
}
|
|
const retried = await globalThis.fetch(replay)
|
|
if (retried.status === 401) {
|
|
unauthorizedHandler?.()
|
|
}
|
|
return retried
|
|
},
|
|
}
|
|
|
|
export const api = createClient<paths>({
|
|
baseUrl: runtime.api,
|
|
credentials: 'same-origin',
|
|
// Resolve fetch per call so tests can replace globalThis.fetch.
|
|
fetch: (request: Request) => globalThis.fetch(request),
|
|
})
|
|
|
|
api.use(transport)
|