feat: Reinitialize frontend with SvelteKit and TypeScript
- Delete old Vite+Svelte frontend - Initialize new SvelteKit project with TypeScript - Configure Tailwind CSS v4 + DaisyUI - Implement JWT authentication with auto-refresh - Create login page with form validation (Zod) - Add protected route guards - Update Docker configuration for single-stage build - Add E2E tests with Playwright (6/11 passing) - Fix Svelte 5 reactivity with $state() runes Known issues: - 5 E2E tests failing (timing/async issues) - Token refresh implementation needs debugging - Validation error display timing
This commit is contained in:
224
frontend/src/lib/services/api.ts
Normal file
224
frontend/src/lib/services/api.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* API Client Service
|
||||
*
|
||||
* Fetch wrapper with JWT token handling, automatic refresh,
|
||||
* and standardized error handling for the Headroom API.
|
||||
*/
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000/api';
|
||||
|
||||
// Token storage keys
|
||||
const ACCESS_TOKEN_KEY = 'headroom_access_token';
|
||||
const REFRESH_TOKEN_KEY = 'headroom_refresh_token';
|
||||
|
||||
// Token management
|
||||
export function getAccessToken(): string | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function getRefreshToken(): string | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setTokens(accessToken: string, refreshToken: string): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
// API Error type
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
data: unknown;
|
||||
|
||||
constructor(message: string, status: number, data: unknown) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
// Queue for requests waiting for token refresh
|
||||
let isRefreshing = false;
|
||||
let refreshSubscribers: Array<(token: string) => void> = [];
|
||||
|
||||
function subscribeTokenRefresh(callback: (token: string) => void): void {
|
||||
refreshSubscribers.push(callback);
|
||||
}
|
||||
|
||||
function onTokenRefreshed(newToken: string): void {
|
||||
refreshSubscribers.forEach((callback) => {
|
||||
callback(newToken);
|
||||
});
|
||||
refreshSubscribers = [];
|
||||
}
|
||||
|
||||
// Refresh token function
|
||||
async function refreshAccessToken(): Promise<string> {
|
||||
const refreshToken = getRefreshToken();
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
clearTokens();
|
||||
throw new Error('Token refresh failed');
|
||||
}
|
||||
|
||||
const data = await response.json() as { access_token: string; refresh_token: string };
|
||||
setTokens(data.access_token, data.refresh_token);
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
// API request options type
|
||||
interface ApiRequestOptions {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
// Main API request function
|
||||
export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions = {}): Promise<T> {
|
||||
const url = `${API_BASE_URL}${endpoint}`;
|
||||
|
||||
// Prepare headers
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
// Add auth token if available
|
||||
const token = getAccessToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Prepare request options
|
||||
const requestOptions: RequestInit = {
|
||||
...options,
|
||||
headers,
|
||||
};
|
||||
|
||||
// Handle request body
|
||||
if (options.body && typeof options.body === 'object') {
|
||||
requestOptions.body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
try {
|
||||
let response = await fetch(url, requestOptions);
|
||||
|
||||
// Handle token expiration
|
||||
if (response.status === 401 && token) {
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const newToken = await refreshAccessToken();
|
||||
isRefreshing = false;
|
||||
onTokenRefreshed(newToken);
|
||||
} catch (error) {
|
||||
isRefreshing = false;
|
||||
refreshSubscribers = [];
|
||||
clearTokens();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('auth:logout'));
|
||||
}
|
||||
throw new ApiError('Session expired. Please log in again.', 401, null);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for token refresh and retry request
|
||||
return new Promise((resolve, reject) => {
|
||||
subscribeTokenRefresh((newToken: string) => {
|
||||
requestOptions.headers = {
|
||||
...requestOptions.headers,
|
||||
'Authorization': `Bearer ${newToken}`,
|
||||
};
|
||||
fetch(url, requestOptions)
|
||||
.then((res) => handleResponse<T>(res))
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return handleResponse<T>(response);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle API response
|
||||
async function handleResponse<T>(response: Response): Promise<T> {
|
||||
const contentType = response.headers?.get?.('content-type') || response.headers?.get?.('Content-Type');
|
||||
const isJson = contentType && contentType.includes('application/json');
|
||||
|
||||
const data = isJson ? await response.json() : await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = typeof data === 'object' ? data : { message: data };
|
||||
const message = (errorData as { message?: string }).message || 'API request failed';
|
||||
throw new ApiError(message, response.status, errorData);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
// Convenience methods
|
||||
export const api = {
|
||||
get: <T>(endpoint: string, options: ApiRequestOptions = {}) =>
|
||||
apiRequest<T>(endpoint, { ...options, method: 'GET' }),
|
||||
post: <T>(endpoint: string, body: unknown, options: ApiRequestOptions = {}) =>
|
||||
apiRequest<T>(endpoint, { ...options, method: 'POST', body }),
|
||||
put: <T>(endpoint: string, body: unknown, options: ApiRequestOptions = {}) =>
|
||||
apiRequest<T>(endpoint, { ...options, method: 'PUT', body }),
|
||||
patch: <T>(endpoint: string, body: unknown, options: ApiRequestOptions = {}) =>
|
||||
apiRequest<T>(endpoint, { ...options, method: 'PATCH', body }),
|
||||
delete: <T>(endpoint: string, options: ApiRequestOptions = {}) =>
|
||||
apiRequest<T>(endpoint, { ...options, method: 'DELETE' }),
|
||||
};
|
||||
|
||||
// Login credentials type
|
||||
interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// Login response type
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Auth-specific API methods
|
||||
export const authApi = {
|
||||
login: (credentials: LoginCredentials) =>
|
||||
api.post<LoginResponse>('/auth/login', credentials),
|
||||
logout: () => api.post<void>('/auth/logout'),
|
||||
refresh: () => api.post<LoginResponse>('/auth/refresh', { refresh_token: getRefreshToken() }),
|
||||
};
|
||||
|
||||
export default api;
|
||||
Reference in New Issue
Block a user