Some checks are pending
E2E / Playwright e2e (push) Waiting to run
- 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.
235 lines
6.6 KiB
TypeScript
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")
|