Files
summercms/admin/src/api/client.ts
Jakub Zych ea33296799 feat(12.2-04): add the fileupload field with deferred uploads on the form session key
- 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
2026-10-02 19:22:22 +02:00

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)