ultisuite-client/lib/api/client.ts
R3D347HR4Y 07d57f13a8
Some checks are pending
E2E / Playwright e2e (push) Waiting to run
Add Contact Avatar Features and Improve UI Components
- Introduced new ContactAvatar and ContactAvatarPicker components for enhanced avatar management in contact views.
- Updated ContactDetailView and ContactFormView to utilize the new avatar components, improving user experience when adding or editing contacts.
- Enhanced ContactHoverCard and ContactRow components to display avatars, providing a more visually appealing interface.
- Added loading and error states in ContactsListView for better user feedback during data fetching.
- Implemented a new ContactsLoadState component to handle loading and error scenarios in the contacts list.
- Updated package.json to include @formkit/auto-animate for improved UI animations.
2026-06-06 20:26:51 +02:00

233 lines
6.2 KiB
TypeScript

import { useAuthStore } from "./auth-store"
import type { ApiError } from "./types"
import type { PlatformUser } from "@/lib/auth/jwt-claims"
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
async function tryRefreshSession(): Promise<boolean> {
try {
const res = await fetch("/api/auth/session", { credentials: "include" })
if (!res.ok) return false
const data = (await res.json()) as {
authenticated?: boolean
accessToken?: string
refreshToken?: string | null
expiresAt?: number
user?: unknown
}
if (data.authenticated && data.accessToken && data.expiresAt) {
useAuthStore.getState().login(
data.accessToken,
data.refreshToken ?? "",
data.expiresAt,
(data.user as PlatformUser | null | undefined) ?? null
)
return true
}
} catch {
// ignore
}
return false
}
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 getHeaders(): HeadersInit {
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
const token = useAuthStore.getState().accessToken
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: { ...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 && !authRetried) {
authRetried = true
if (await tryRefreshSession()) {
continue
}
}
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): Promise<Blob> {
if (typeof navigator !== "undefined" && !navigator.onLine) {
throw new OfflineError()
}
const url = this.resolveUrl(path)
const headers: Record<string, string> = {}
const token = useAuthStore.getState().accessToken
if (token) {
headers["Authorization"] = `Bearer ${token}`
}
const response = await fetch(url.toString(), { method: "GET", headers })
if (!response.ok) {
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")