Initial commit
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { getBasePath } from '@/lib/basePath';
|
||||
|
||||
export function getApiBaseUrl(): string {
|
||||
const envUrl = import.meta.env.VITE_API_BASE_URL as string | undefined;
|
||||
if (envUrl && envUrl.length > 0) {
|
||||
return envUrl.replace(/\/+$/, '');
|
||||
}
|
||||
return window.location.origin;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
statusText: string;
|
||||
body: unknown;
|
||||
|
||||
constructor(status: number, statusText: string, body: unknown) {
|
||||
super(`API error ${status}: ${statusText}`);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.statusText = statusText;
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<void> | null = null;
|
||||
|
||||
export async function refreshAccessToken(): Promise<void> {
|
||||
if (refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
refreshPromise = (async () => {
|
||||
const { refreshToken, tokenEndpoint, logout } = useAuthStore.getState();
|
||||
|
||||
if (!refreshToken || !tokenEndpoint) {
|
||||
logout();
|
||||
window.location.href = `${getBasePath()}/login`;
|
||||
throw new Error('No refresh token or token endpoint available');
|
||||
}
|
||||
|
||||
const clientId = (import.meta.env.VITE_OAUTH_CLIENT_ID as string) || 'stalwart-webui';
|
||||
|
||||
try {
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token refresh failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
useAuthStore
|
||||
.getState()
|
||||
.setTokens(data.access_token, data.refresh_token ?? refreshToken, data.expires_in, tokenEndpoint);
|
||||
} catch (error) {
|
||||
useAuthStore.getState().logout();
|
||||
window.location.href = `${getBasePath()}/login`;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await refreshPromise;
|
||||
} finally {
|
||||
refreshPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch(path: string, options?: RequestInit): Promise<Response> {
|
||||
const store = useAuthStore.getState();
|
||||
|
||||
if (store.isTokenExpiringSoon() && store.refreshToken) {
|
||||
await refreshAccessToken();
|
||||
}
|
||||
|
||||
const makeRequest = async (): Promise<Response> => {
|
||||
const { accessToken } = useAuthStore.getState();
|
||||
const url = `${getApiBaseUrl()}${path}`;
|
||||
|
||||
const headers = new Headers(options?.headers);
|
||||
if (accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
let response = await makeRequest();
|
||||
|
||||
if (response.status === 401 && useAuthStore.getState().refreshToken) {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
response = await makeRequest();
|
||||
} catch {
|
||||
throw new ApiError(401, 'Unauthorized', null);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
let body: unknown = null;
|
||||
try {
|
||||
body = await response.json();
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch {}
|
||||
throw new ApiError(response.status, response.statusText, body);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
Reference in New Issue
Block a user