ultisuite-client/lib/api/client.ts
R3D347HR4Y 5304790ed5
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
feat(auth): enhance session management and identity provider settings
- Added SessionGuard component to manage session expiration and online status.
- Updated AuthProvider to streamline session fetching and handling.
- Introduced IdentityProvidersSection for managing OAuth, SAML, and LDAP identity providers.
- Implemented identity provider guides for easier configuration.
- Enhanced mail settings with infinite scroll option for improved user experience.
- Updated global styles and layout components for better consistency across the application.
2026-06-09 09:36:46 +02:00

235 lines
6.6 KiB
TypeScript

import type { ApiError } from "./types"
import { ensureAccessToken } from "@/lib/auth/ensure-access-token"
import { handleUnauthorized } from "@/lib/auth/handle-unauthorized"
import { isSessionExpired } from "@/lib/auth/session-guard-store"
export class OfflineError extends Error {
constructor() {
super("Device is offline")
this.name = "OfflineError"
}
}
export class ApiRequestError extends Error {
code: string
details?: unknown
status: number
constructor(status: number, code: string, message: string, details?: unknown) {
super(message)
this.name = "ApiRequestError"
this.status = status
this.code = code
this.details = details
}
}
const DEFAULT_TIMEOUT = 10_000
const DEFAULT_RETRIES = 3
const BASE_DELAY = 1000
class ApiClient {
constructor(private baseUrl: string) {}
private resolveUrl(path: string): URL {
const base = this.baseUrl.startsWith("http")
? this.baseUrl
: `${typeof window !== "undefined" ? window.location.origin : "http://localhost"}${this.baseUrl}`
// Absolute path (leading /) would replace /api/v1 — keep base path segment.
const normalizedBase = base.endsWith("/") ? base : `${base}/`
const normalizedPath = path.startsWith("/") ? path.slice(1) : path
return new URL(normalizedPath, normalizedBase)
}
private async getHeaders(): Promise<HeadersInit> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
const token = await ensureAccessToken()
if (token) {
headers["Authorization"] = `Bearer ${token}`
}
return headers
}
private async request<T>(
method: string,
path: string,
opts?: {
body?: unknown
params?: Record<string, string | undefined>
headers?: Record<string, string>
timeout?: number
retries?: number
}
): Promise<T> {
if (typeof navigator !== "undefined" && !navigator.onLine) {
throw new OfflineError()
}
const url = this.resolveUrl(path)
if (opts?.params) {
for (const [key, value] of Object.entries(opts.params)) {
if (value !== undefined) {
url.searchParams.set(key, value)
}
}
}
const timeout = opts?.timeout ?? DEFAULT_TIMEOUT
const maxRetries = opts?.retries ?? DEFAULT_RETRIES
let lastError: Error | null = null
let authRetried = false
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (attempt > 0) {
const delay = BASE_DELAY * Math.pow(2, attempt - 1)
await new Promise((resolve) => setTimeout(resolve, delay))
}
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url.toString(), {
method,
headers: { ...(await this.getHeaders()), ...opts?.headers },
body: opts?.body ? JSON.stringify(opts.body) : undefined,
signal: controller.signal,
})
clearTimeout(timer)
if (!response.ok) {
let errorBody: ApiError | undefined
try {
errorBody = await response.json()
} catch {}
const err = new ApiRequestError(
response.status,
errorBody?.code ?? "UNKNOWN",
errorBody?.message ?? response.statusText,
errorBody?.details
)
if (response.status === 401) {
if (isSessionExpired()) {
throw err
}
if (!authRetried) {
authRetried = true
const resolution = await handleUnauthorized()
if (resolution === "refreshed") {
continue
}
if (resolution === "offline") {
throw new OfflineError()
}
} else {
await handleUnauthorized({ forceExpired: true })
}
}
if (response.status >= 400 && response.status < 500) {
throw err
}
lastError = err
continue
}
if (response.status === 204) {
return undefined as T
}
const text = await response.text()
if (!text.trim()) {
return undefined as T
}
return JSON.parse(text) as T
} catch (err) {
clearTimeout(timer)
if (err instanceof ApiRequestError && err.status >= 400 && err.status < 500) {
throw err
}
lastError = err instanceof Error ? err : new Error(String(err))
if (err instanceof DOMException && err.name === "AbortError") {
lastError = new Error("Request timed out")
}
}
}
throw lastError ?? new Error("Request failed")
}
async get<T>(path: string, params?: Record<string, string | undefined>): Promise<T> {
return this.request<T>("GET", path, { params })
}
/** GET binary body (inline attachments, exports). */
async getBlob(path: string, authRetried = false): Promise<Blob> {
if (typeof navigator !== "undefined" && !navigator.onLine) {
throw new OfflineError()
}
const url = this.resolveUrl(path)
const headers: Record<string, string> = {}
const token = await ensureAccessToken()
if (token) {
headers["Authorization"] = `Bearer ${token}`
}
const response = await fetch(url.toString(), { method: "GET", headers })
if (!response.ok) {
if (response.status === 401) {
if (isSessionExpired()) {
throw new ApiRequestError(response.status, "UNKNOWN", response.statusText)
}
if (!authRetried) {
const resolution = await handleUnauthorized()
if (resolution === "refreshed") {
return this.getBlob(path, true)
}
if (resolution === "offline") {
throw new OfflineError()
}
} else {
await handleUnauthorized({ forceExpired: true })
}
}
throw new ApiRequestError(
response.status,
"UNKNOWN",
response.statusText
)
}
return response.blob()
}
async post<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("POST", path, { body })
}
async put<T>(
path: string,
body?: unknown,
headers?: Record<string, string>,
): Promise<T> {
return this.request<T>("PUT", path, { body, headers })
}
async patch<T>(path: string, body?: unknown): Promise<T> {
return this.request<T>("PATCH", path, { body })
}
async delete(path: string): Promise<void> {
await this.request<void>("DELETE", path)
}
}
export const apiClient = new ApiClient(process.env.NEXT_PUBLIC_API_URL ?? "/api/v1")