Files
Stirling-PDF/frontend/src/desktop/services/apiClientSetup.ts
T
Anthony StirlingandGitHub 558c75a2b1 JWT enhancements for desktop (#5742)
# Description of Changes

This is temporary solution which will be enhanced in future

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-16 21:57:42 +00:00

182 lines
6.9 KiB
TypeScript

import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import { alert } from '@app/components/toast';
import { setupApiInterceptors as coreSetup } from '@core/services/apiClientSetup';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { createBackendNotReadyError } from '@app/constants/backendErrors';
import { operationRouter } from '@app/services/operationRouter';
import { authService } from '@app/services/authService';
import { connectionModeService } from '@app/services/connectionModeService';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import i18n from '@app/i18n';
const BACKEND_TOAST_COOLDOWN_MS = 4000;
let lastBackendToast = 0;
// Extended config for custom properties
interface ExtendedRequestConfig extends InternalAxiosRequestConfig {
operationName?: string;
skipBackendReadyCheck?: boolean;
skipAuthRedirect?: boolean;
_retry?: boolean;
}
/**
* Desktop-specific API interceptors
* - Reuses the core interceptors
* - Dynamically sets base URL based on connection mode
* - Adds auth token for remote server requests
* - Blocks API calls while the bundled backend is still starting
* - Handles auth token refresh on 401 errors
*/
export function setupApiInterceptors(client: AxiosInstance): void {
coreSetup(client);
// Request interceptor: Set base URL and auth headers dynamically
client.interceptors.request.use(
async (config: InternalAxiosRequestConfig) => {
const extendedConfig = config as ExtendedRequestConfig;
try {
// Get the appropriate base URL for this request
const baseUrl = await operationRouter.getBaseUrl(extendedConfig.url);
// Build the full URL
if (extendedConfig.url && !extendedConfig.url.startsWith('http')) {
extendedConfig.url = `${baseUrl}${extendedConfig.url}`;
}
localStorage.setItem('server_url', baseUrl);
// Debug logging
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
// Add auth token for remote requests and enable credentials
const isRemote = await operationRouter.isSelfHostedMode();
if (isRemote) {
// Self-hosted mode: enable credentials for session management
extendedConfig.withCredentials = true;
// If another request is already refreshing, wait before attaching token.
await authService.awaitRefreshIfInProgress();
const token = await authService.getAuthToken();
if (token) {
extendedConfig.headers.Authorization = `Bearer ${token}`;
} else {
console.warn('[apiClientSetup] Self-hosted mode but no auth token available');
}
} else {
// SaaS mode: disable credentials (security disabled on local backend)
extendedConfig.withCredentials = false;
}
} catch (error) {
console.error('[apiClientSetup] Error in request interceptor:', error);
// Continue with request even if routing/auth logic fails
// This ensures requests aren't blocked by interceptor errors
}
// Backend readiness check (for local backend)
const skipCheck = extendedConfig.skipBackendReadyCheck === true;
const isSaaS = await operationRouter.isSaaSMode();
if (isSaaS && !skipCheck && !tauriBackendService.isBackendHealthy()) {
const method = (extendedConfig.method || 'get').toLowerCase();
if (method !== 'get') {
const now = Date.now();
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
lastBackendToast = now;
alert({
alertType: 'error',
title: i18n.t('backendHealth.offline', 'Backend Offline'),
body: i18n.t('backendHealth.wait', 'Please wait for the backend to finish launching and try again.'),
isPersistentPopup: false,
});
}
}
return Promise.reject(createBackendNotReadyError());
}
return extendedConfig;
},
(error) => Promise.reject(error)
);
// Response interceptor: Handle auth errors
client.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config as ExtendedRequestConfig;
const requestUrl = String(originalRequest?.url || '');
const isAuthProbeRequest = requestUrl.includes('/api/v1/auth/me');
// Handle 401 Unauthorized - try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
// `/auth/me` is used as a probe by session bootstrap; refreshing here can
// create recursion (refresh -> save token -> jwt-available -> /auth/me).
if (isAuthProbeRequest) {
return Promise.reject(error);
}
if (typeof window !== 'undefined') {
console.warn('[apiClientSetup] 401 on path:', window.location.pathname, 'url:', originalRequest.url);
}
if (originalRequest.skipAuthRedirect) {
return Promise.reject(error);
}
originalRequest._retry = true;
console.debug(`[apiClientSetup] 401 error, attempting token refresh for: ${originalRequest.url}`);
const isRemote = await operationRouter.isSelfHostedMode();
let refreshed = false;
if (isRemote) {
// Self-hosted mode: use Spring Boot refresh endpoint
const serverConfig = await connectionModeService.getServerConfig();
if (serverConfig) {
refreshed = await authService.refreshToken(serverConfig.url);
}
} else {
// SaaS mode: use Supabase refresh endpoint
refreshed = await authService.refreshSupabaseToken(STIRLING_SAAS_URL);
}
if (refreshed) {
// Retry the original request with new token
const token = await authService.getAuthToken();
console.debug(`[apiClientSetup] Token refreshed, retrying request to: ${originalRequest.url}`);
if (token) {
originalRequest.headers.Authorization = `Bearer ${token}`;
} else {
console.error(`[apiClientSetup] No token available after successful refresh!`);
}
return client.request(originalRequest);
}
// Refresh failed - user needs to login again
alert({
alertType: 'error',
title: i18n.t('auth.sessionExpired', 'Session Expired'),
body: i18n.t('auth.pleaseLoginAgain', 'Please login again.'),
isPersistentPopup: false,
});
}
// Handle 403 Forbidden - unauthorized access
if (error.response?.status === 403) {
alert({
alertType: 'error',
title: i18n.t('auth.accessDenied', 'Access Denied'),
body: i18n.t('auth.insufficientPermissions', 'You do not have permission to perform this action.'),
isPersistentPopup: false,
});
}
return Promise.reject(error);
}
);
}