aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--src/client/api/client.ts58
-rw-r--r--src/client/stores/sync.test.tsx3
-rw-r--r--src/client/stores/sync.tsx19
3 files changed, 60 insertions, 20 deletions
diff --git a/src/client/api/client.ts b/src/client/api/client.ts
index 7607eb6..b39f064 100644
--- a/src/client/api/client.ts
+++ b/src/client/api/client.ts
@@ -61,12 +61,50 @@ export function createClient(baseUrl: string): Client {
export class ApiClient {
private tokenStorage: TokenStorage;
private refreshPromise: Promise<boolean> | null = null;
+ private baseUrl: string;
public readonly rpc: Client;
constructor(options: ApiClientOptions = {}) {
- const baseUrl = options.baseUrl ?? window.location.origin;
+ this.baseUrl = options.baseUrl ?? window.location.origin;
this.tokenStorage = options.tokenStorage ?? localStorageTokenStorage;
- this.rpc = createClient(baseUrl);
+ this.rpc = this.createAuthenticatedClient();
+ }
+
+ private createAuthenticatedClient(): Client {
+ return hc<AppType>(this.baseUrl, {
+ fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
+ return this.authenticatedFetch(input, init);
+ },
+ });
+ }
+
+ async authenticatedFetch(
+ input: RequestInfo | URL,
+ init?: RequestInit,
+ ): Promise<Response> {
+ const tokens = this.tokenStorage.getTokens();
+ const headers = new Headers(init?.headers);
+
+ if (tokens?.accessToken && !headers.has("Authorization")) {
+ headers.set("Authorization", `Bearer ${tokens.accessToken}`);
+ }
+
+ const response = await fetch(input, { ...init, headers });
+
+ if (response.status === 401 && tokens?.refreshToken) {
+ // Try to refresh the token
+ const refreshed = await this.refreshToken();
+ if (refreshed) {
+ // Retry with new token
+ const newTokens = this.tokenStorage.getTokens();
+ if (newTokens?.accessToken) {
+ headers.set("Authorization", `Bearer ${newTokens.accessToken}`);
+ }
+ return fetch(input, { ...init, headers });
+ }
+ }
+
+ return response;
}
private async handleResponse<T>(response: Response): Promise<T> {
@@ -108,15 +146,25 @@ export class ApiClient {
}
try {
- const res = await this.rpc.api.auth.refresh.$post({
- json: { refreshToken: tokens.refreshToken },
+ // Use raw fetch to avoid infinite loop through authenticatedFetch
+ const res = await fetch(`${this.baseUrl}/api/auth/refresh`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ refreshToken: tokens.refreshToken }),
});
if (!res.ok) {
+ // Clear tokens if refresh fails
+ this.tokenStorage.clearTokens();
return false;
}
- const data = await res.json();
+ const data = (await res.json()) as {
+ accessToken: string;
+ refreshToken: string;
+ };
this.tokenStorage.setTokens({
accessToken: data.accessToken,
refreshToken: data.refreshToken,
diff --git a/src/client/stores/sync.test.tsx b/src/client/stores/sync.test.tsx
index fee79d7..20de69d 100644
--- a/src/client/stores/sync.test.tsx
+++ b/src/client/stores/sync.test.tsx
@@ -16,6 +16,9 @@ global.fetch = mockFetch;
vi.mock("../api/client", () => ({
apiClient: {
getAuthHeader: vi.fn(() => ({ Authorization: "Bearer token" })),
+ authenticatedFetch: vi.fn((input: RequestInfo | URL, init?: RequestInit) =>
+ mockFetch(input, init),
+ ),
},
}));
diff --git a/src/client/stores/sync.tsx b/src/client/stores/sync.tsx
index aea5c16..9b46302 100644
--- a/src/client/stores/sync.tsx
+++ b/src/client/stores/sync.tsx
@@ -108,15 +108,9 @@ interface PullResponse {
}
async function pushToServer(data: SyncPushData): Promise<SyncPushResult> {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new Error("Not authenticated");
- }
-
- const res = await fetch("/api/sync/push", {
+ const res = await apiClient.authenticatedFetch("/api/sync/push", {
method: "POST",
headers: {
- ...authHeader,
"Content-Type": "application/json",
},
body: JSON.stringify(data),
@@ -135,14 +129,9 @@ async function pushToServer(data: SyncPushData): Promise<SyncPushResult> {
async function pullFromServer(
lastSyncVersion: number,
): Promise<SyncPullResult> {
- const authHeader = apiClient.getAuthHeader();
- if (!authHeader) {
- throw new Error("Not authenticated");
- }
-
- const res = await fetch(`/api/sync/pull?lastSyncVersion=${lastSyncVersion}`, {
- headers: authHeader,
- });
+ const res = await apiClient.authenticatedFetch(
+ `/api/sync/pull?lastSyncVersion=${lastSyncVersion}`,
+ );
if (!res.ok) {
const errorBody = (await res.json().catch(() => ({}))) as {