diff --git a/raggr-frontend/src/api/conversationService.ts b/raggr-frontend/src/api/conversationService.ts index afc2fa9..66bbc98 100644 --- a/raggr-frontend/src/api/conversationService.ts +++ b/raggr-frontend/src/api/conversationService.ts @@ -41,7 +41,7 @@ class ConversationService { conversation_id: string, signal?: AbortSignal, ): Promise { - const response = await userService.fetchWithRefreshToken( + const response = await userService.fetchWithAuth( `${this.conversationBaseUrl}/query`, { method: "POST", @@ -58,7 +58,7 @@ class ConversationService { } async getMessages(): Promise { - const response = await userService.fetchWithRefreshToken( + const response = await userService.fetchWithAuth( `${this.baseUrl}/messages`, { method: "GET", @@ -73,7 +73,7 @@ class ConversationService { } async getConversation(conversationId: string): Promise { - const response = await userService.fetchWithRefreshToken( + const response = await userService.fetchWithAuth( `${this.conversationBaseUrl}/${conversationId}`, { method: "GET", @@ -88,7 +88,7 @@ class ConversationService { } async createConversation(): Promise { - const response = await userService.fetchWithRefreshToken( + const response = await userService.fetchWithAuth( `${this.conversationBaseUrl}/`, { method: "POST", @@ -106,7 +106,7 @@ class ConversationService { conversationId: string, name: string, ): Promise { - const response = await userService.fetchWithRefreshToken( + const response = await userService.fetchWithAuth( `${this.conversationBaseUrl}/${conversationId}`, { method: "PATCH", @@ -122,7 +122,7 @@ class ConversationService { } async deleteConversation(conversationId: string): Promise { - const response = await userService.fetchWithRefreshToken( + const response = await userService.fetchWithAuth( `${this.conversationBaseUrl}/${conversationId}`, { method: "DELETE", @@ -145,7 +145,7 @@ class ConversationService { if (params?.search) search.set("search", params.search); const qs = search.toString(); - const response = await userService.fetchWithRefreshToken( + const response = await userService.fetchWithAuth( `${this.conversationBaseUrl}/${qs ? `?${qs}` : ""}`, { method: "GET", @@ -167,7 +167,7 @@ class ConversationService { formData.append("file", file); formData.append("conversation_id", conversationId); - const response = await userService.fetchWithRefreshToken( + const response = await userService.fetchWithAuth( `${this.conversationBaseUrl}/upload-image`, { method: "POST", @@ -185,7 +185,7 @@ class ConversationService { } async getPresignedImageUrl(imageKey: string): Promise { - const response = await userService.fetchWithRefreshToken( + const response = await userService.fetchWithAuth( `${this.conversationBaseUrl}/image/${imageKey}`, ); if (!response.ok) { @@ -207,7 +207,7 @@ class ConversationService { body.image_key = imageKey; } - const response = await userService.fetchWithRefreshToken( + const response = await userService.fetchWithAuth( `${this.conversationBaseUrl}/stream-query`, { method: "POST", diff --git a/raggr-frontend/src/api/oidcService.ts b/raggr-frontend/src/api/oidcService.ts index 98df0b3..5b2d3e2 100644 --- a/raggr-frontend/src/api/oidcService.ts +++ b/raggr-frontend/src/api/oidcService.ts @@ -24,14 +24,11 @@ class OIDCService { * Initiate OIDC login flow * Returns authorization URL to redirect user to */ - async initiateLogin(redirectAfterLogin: string = "/"): Promise { - const response = await fetch( - `${this.baseUrl}/login?redirect=${encodeURIComponent(redirectAfterLogin)}`, - { - method: "GET", - headers: { "Content-Type": "application/json" }, - } - ); + async initiateLogin(): Promise { + const response = await fetch(`${this.baseUrl}/login`, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); if (!response.ok) { throw new Error("Failed to initiate OIDC login"); diff --git a/raggr-frontend/src/api/scheduledMessageService.ts b/raggr-frontend/src/api/scheduledMessageService.ts index e015cc4..5881c08 100644 --- a/raggr-frontend/src/api/scheduledMessageService.ts +++ b/raggr-frontend/src/api/scheduledMessageService.ts @@ -29,13 +29,13 @@ class ScheduledMessageService { private baseUrl = "/api/scheduled-messages"; async list(): Promise { - const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/`); + const response = await userService.fetchWithAuth(`${this.baseUrl}/`); if (!response.ok) throw new Error("Failed to list scheduled messages"); return response.json(); } async create(data: CreateScheduledMessage): Promise { - const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/`, { + const response = await userService.fetchWithAuth(`${this.baseUrl}/`, { method: "POST", body: JSON.stringify(data), }); @@ -47,7 +47,7 @@ class ScheduledMessageService { } async update(id: string, data: Partial & { status?: string }): Promise { - const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/${id}`, { + const response = await userService.fetchWithAuth(`${this.baseUrl}/${id}`, { method: "PUT", body: JSON.stringify(data), }); @@ -59,7 +59,7 @@ class ScheduledMessageService { } async remove(id: string): Promise { - const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/${id}`, { + const response = await userService.fetchWithAuth(`${this.baseUrl}/${id}`, { method: "DELETE", }); if (!response.ok) { diff --git a/raggr-frontend/src/api/userService.ts b/raggr-frontend/src/api/userService.ts index 52e4f4c..4966a2a 100644 --- a/raggr-frontend/src/api/userService.ts +++ b/raggr-frontend/src/api/userService.ts @@ -29,7 +29,22 @@ class UserService { return await response.json(); } + private refreshInFlight: Promise | null = null; + + /** + * Exchange the refresh token for a new access token. The refresh token is + * only ever sent here. Concurrent callers share one in-flight request. + */ async refreshToken(): Promise { + if (!this.refreshInFlight) { + this.refreshInFlight = this.doRefresh().finally(() => { + this.refreshInFlight = null; + }); + } + return this.refreshInFlight; + } + + private async doRefresh(): Promise { const refreshToken = localStorage.getItem("refresh_token"); if (!refreshToken) { @@ -71,85 +86,65 @@ class UserService { } } + /** + * Authenticated fetch: sends the access token and, on a 401 (expired or + * missing access token), refreshes once and retries. With no stored access + * token it refreshes up front instead of making a doomed request. + */ async fetchWithAuth( url: string, options: RequestInit = {}, - ): Promise { - const accessToken = localStorage.getItem("access_token"); - - // Add authorization header - const headers = { - "Content-Type": "application/json", - ...(options.headers || {}), - ...(accessToken && { Authorization: `Bearer ${accessToken}` }), - }; - - let response = await fetch(url, { ...options, headers }); - - // If unauthorized, try refreshing the token - if (response.status === 401) { - try { - const newAccessToken = await this.refreshToken(); - - // Retry the request with new token - headers.Authorization = `Bearer ${newAccessToken}`; - response = await fetch(url, { ...options, headers }); - } catch (error) { - // Refresh failed, redirect to login or throw error - throw new Error("Session expired. Please log in again."); - } - } - - return response; - } - - async fetchWithRefreshToken( - url: string, - options: RequestInit = {}, { skipContentType = false }: { skipContentType?: boolean } = {}, ): Promise { - const refreshToken = localStorage.getItem("refresh_token"); - - // Add authorization header - const headers: Record = { + const buildHeaders = (token: string | null): Record => ({ ...(skipContentType ? {} : { "Content-Type": "application/json" }), ...((options.headers as Record) || {}), - ...(refreshToken ? { Authorization: `Bearer ${refreshToken}` } : {}), - }; + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }); - let response = await fetch(url, { ...options, headers }); - - // If unauthorized, try refreshing the token - if (response.status === 401) { + const refreshOrExpire = async (): Promise => { try { - const newAccessToken = await this.refreshToken(); - - // Retry the request with new token - headers.Authorization = `Bearer ${newAccessToken}`; - response = await fetch(url, { ...options, headers }); - } catch (error) { - // Refresh failed, redirect to login or throw error + return await this.refreshToken(); + } catch { throw new Error("Session expired. Please log in again."); } + }; + + let accessToken = localStorage.getItem("access_token"); + if (!accessToken) { + accessToken = await refreshOrExpire(); + } + + let response = await fetch(url, { + ...options, + headers: buildHeaders(accessToken), + }); + + if (response.status === 401) { + accessToken = await refreshOrExpire(); + response = await fetch(url, { + ...options, + headers: buildHeaders(accessToken), + }); } return response; } async getMe(): Promise<{ id: string; username: string; email: string; is_admin: boolean }> { - const response = await this.fetchWithRefreshToken(`${this.baseUrl}/me`); + const response = await this.fetchWithAuth(`${this.baseUrl}/me`); if (!response.ok) throw new Error("Failed to fetch user profile"); return response.json(); } async adminListUsers(): Promise { - const response = await this.fetchWithRefreshToken(`${this.baseUrl}/admin/users`); + const response = await this.fetchWithAuth(`${this.baseUrl}/admin/users`); if (!response.ok) throw new Error("Failed to list users"); return response.json(); } async adminSetWhatsapp(userId: string, number: string): Promise { - const response = await this.fetchWithRefreshToken( + const response = await this.fetchWithAuth( `${this.baseUrl}/admin/users/${userId}/whatsapp`, { method: "PUT", body: JSON.stringify({ whatsapp_number: number }) }, ); @@ -162,7 +157,7 @@ class UserService { } async adminUnlinkWhatsapp(userId: string): Promise { - const response = await this.fetchWithRefreshToken( + const response = await this.fetchWithAuth( `${this.baseUrl}/admin/users/${userId}/whatsapp`, { method: "DELETE" }, ); @@ -170,7 +165,7 @@ class UserService { } async adminToggleEmail(userId: string): Promise { - const response = await this.fetchWithRefreshToken( + const response = await this.fetchWithAuth( `${this.baseUrl}/admin/users/${userId}/email`, { method: "PUT" }, ); @@ -179,7 +174,7 @@ class UserService { } async adminDisableEmail(userId: string): Promise { - const response = await this.fetchWithRefreshToken( + const response = await this.fetchWithAuth( `${this.baseUrl}/admin/users/${userId}/email`, { method: "DELETE" }, );