Merge pull request 'Frontend: send access token, refresh once on 401' (#59) from fix/access-token-auth into main

This commit was merged in pull request #59.
This commit is contained in:
2026-10-12 17:49:18 -04:00
4 changed files with 69 additions and 77 deletions
+10 -10
View File
@@ -41,7 +41,7 @@ class ConversationService {
conversation_id: string,
signal?: AbortSignal,
): Promise<QueryResponse> {
const response = await userService.fetchWithRefreshToken(
const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/query`,
{
method: "POST",
@@ -58,7 +58,7 @@ class ConversationService {
}
async getMessages(): Promise<Conversation> {
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<Conversation> {
const response = await userService.fetchWithRefreshToken(
const response = await userService.fetchWithAuth(
`${this.conversationBaseUrl}/${conversationId}`,
{
method: "GET",
@@ -88,7 +88,7 @@ class ConversationService {
}
async createConversation(): Promise<Conversation> {
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<Conversation> {
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<void> {
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<string> {
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",
+5 -8
View File
@@ -24,14 +24,11 @@ class OIDCService {
* Initiate OIDC login flow
* Returns authorization URL to redirect user to
*/
async initiateLogin(redirectAfterLogin: string = "/"): Promise<string> {
const response = await fetch(
`${this.baseUrl}/login?redirect=${encodeURIComponent(redirectAfterLogin)}`,
{
method: "GET",
headers: { "Content-Type": "application/json" },
}
);
async initiateLogin(): Promise<string> {
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");
@@ -29,13 +29,13 @@ class ScheduledMessageService {
private baseUrl = "/api/scheduled-messages";
async list(): Promise<ScheduledMessage[]> {
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<ScheduledMessage> {
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<CreateScheduledMessage> & { status?: string }): Promise<ScheduledMessage> {
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<void> {
const response = await userService.fetchWithRefreshToken(`${this.baseUrl}/${id}`, {
const response = await userService.fetchWithAuth(`${this.baseUrl}/${id}`, {
method: "DELETE",
});
if (!response.ok) {
+50 -55
View File
@@ -29,7 +29,22 @@ class UserService {
return await response.json();
}
private refreshInFlight: Promise<string> | 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<string> {
if (!this.refreshInFlight) {
this.refreshInFlight = this.doRefresh().finally(() => {
this.refreshInFlight = null;
});
}
return this.refreshInFlight;
}
private async doRefresh(): Promise<string> {
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<Response> {
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<Response> {
const refreshToken = localStorage.getItem("refresh_token");
// Add authorization header
const headers: Record<string, string> = {
const buildHeaders = (token: string | null): Record<string, string> => ({
...(skipContentType ? {} : { "Content-Type": "application/json" }),
...((options.headers as Record<string, string>) || {}),
...(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<string> => {
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<AdminUserRecord[]> {
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<AdminUserRecord> {
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<void> {
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<AdminUserRecord> {
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<void> {
const response = await this.fetchWithRefreshToken(
const response = await this.fetchWithAuth(
`${this.baseUrl}/admin/users/${userId}/email`,
{ method: "DELETE" },
);