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:
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user