feat(web): add authenticated file workflow
- feat: connect login and logout to the REST API with session-scoped tokens, protected routing, single-flight refresh, and authenticated query cache cleanup. - feat: add the Ant Design application shell with root file listing, pagination, small-file upload, and authenticated download. - test: cover session storage and API retry and error behavior with Vitest. - docs: document the browser milestone, same-origin API topology, and deferred transfer and account features.
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { sessionStore, type SessionStore } from './session.ts'
|
||||
import type { ErrorResponse, TokenPair } from './types.ts'
|
||||
|
||||
const defaultApiBase = '/api/v1'
|
||||
|
||||
type ResponseType = 'blob' | 'json' | 'void'
|
||||
|
||||
export interface ApiRequestOptions {
|
||||
auth?: boolean
|
||||
body?: BodyInit | null
|
||||
headers?: HeadersInit
|
||||
json?: unknown
|
||||
method?: string
|
||||
responseType?: ResponseType
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
interface ApiClientOptions {
|
||||
apiBase?: string
|
||||
fetchImplementation?: typeof fetch
|
||||
store?: SessionStore
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number
|
||||
readonly logId?: string
|
||||
|
||||
constructor(status: number, message: string, logId?: string) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.logId = logId
|
||||
}
|
||||
}
|
||||
|
||||
async function responseError(response: Response): Promise<ApiError> {
|
||||
let body: ErrorResponse | undefined
|
||||
try {
|
||||
body = (await response.json()) as ErrorResponse
|
||||
} catch {
|
||||
// Non-JSON failures use a stable fallback message.
|
||||
}
|
||||
|
||||
const message =
|
||||
body?.error?.message ?? `Request failed with status ${response.status}`
|
||||
return new ApiError(response.status, message, body?.error?.log_id)
|
||||
}
|
||||
|
||||
async function readResponse<T>(
|
||||
response: Response,
|
||||
responseType: ResponseType,
|
||||
): Promise<T> {
|
||||
if (!response.ok) {
|
||||
throw await responseError(response)
|
||||
}
|
||||
|
||||
if (responseType === 'blob') {
|
||||
return (await response.blob()) as T
|
||||
}
|
||||
if (responseType === 'void' || response.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
export function createApiClient({
|
||||
apiBase = defaultApiBase,
|
||||
fetchImplementation = fetch,
|
||||
store = sessionStore,
|
||||
}: ApiClientOptions = {}) {
|
||||
let refreshPromise: Promise<TokenPair> | null = null
|
||||
|
||||
const refreshSession = (): Promise<TokenPair> => {
|
||||
if (refreshPromise) {
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
const refreshToken = store.get()?.refresh_token
|
||||
if (!refreshToken) {
|
||||
store.clear()
|
||||
return Promise.reject(new ApiError(401, 'Your session has expired'))
|
||||
}
|
||||
|
||||
refreshPromise = fetchImplementation(`${apiBase}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
})
|
||||
.then((response) => readResponse<TokenPair>(response, 'json'))
|
||||
.then((session) => {
|
||||
store.set(session)
|
||||
return session
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
store.clear()
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
const request = async <T>(
|
||||
path: string,
|
||||
options: ApiRequestOptions = {},
|
||||
): Promise<T> => {
|
||||
const {
|
||||
auth = true,
|
||||
body,
|
||||
headers: providedHeaders,
|
||||
json,
|
||||
method = 'GET',
|
||||
responseType = 'json',
|
||||
signal,
|
||||
} = options
|
||||
|
||||
const run = (accessToken?: string) => {
|
||||
const headers = new Headers(providedHeaders)
|
||||
let requestBody = body
|
||||
|
||||
if (json !== undefined) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
requestBody = JSON.stringify(json)
|
||||
}
|
||||
if (accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`)
|
||||
}
|
||||
|
||||
return fetchImplementation(`${apiBase}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: requestBody,
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
const accessToken = auth ? store.get()?.access_token : undefined
|
||||
if (auth && !accessToken) {
|
||||
throw new ApiError(401, 'Authentication required')
|
||||
}
|
||||
|
||||
let response = await run(accessToken)
|
||||
if (auth && response.status === 401) {
|
||||
const refreshedSession = await refreshSession()
|
||||
response = await run(refreshedSession.access_token)
|
||||
if (response.status === 401) {
|
||||
store.clear()
|
||||
}
|
||||
}
|
||||
|
||||
return readResponse<T>(response, responseType)
|
||||
}
|
||||
|
||||
return { request }
|
||||
}
|
||||
|
||||
export const apiClient = createApiClient()
|
||||
Reference in New Issue
Block a user