aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/api
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-04-27 07:10:41 +0900
committernsfisis <nsfisis@gmail.com>2026-04-27 07:10:41 +0900
commite3576b4b3cb0428a6cc738289a66b7951a133320 (patch)
tree2179760cf5ae1107c14254b109737899c706280d /src/client/api
parent38b8fc0e9927c4146b4c8b309b2bcc644abd63d0 (diff)
downloadkioku-e3576b4b3cb0428a6cc738289a66b7951a133320.tar.gz
kioku-e3576b4b3cb0428a6cc738289a66b7951a133320.tar.zst
kioku-e3576b4b3cb0428a6cc738289a66b7951a133320.zip
fix(auth): redirect to login page on session expiry
Previously when the session expired, the API client cleared tokens but the UI displayed "Invalid or expired token" instead of redirecting to the login page. The root cause was that isAuthenticatedAtom was derived from userAtom only as a re-evaluation trigger, while the actual value came from apiClient.isAuthenticated(). On page reload userAtom is null, so setting it to null on session expiry did not trigger a re-render and ProtectedRoute never redirected. Make userAtom (persisted via atomWithStorage) the single source of truth for auth state, derive isAuthenticatedAtom from it, drop the redundant apiClient.isAuthenticated(), and explicitly navigate to /login on session expiry. Also trigger session expiry when a 401 comes back with no refresh token available. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/client/api')
-rw-r--r--src/client/api/client.test.ts21
-rw-r--r--src/client/api/client.ts28
2 files changed, 15 insertions, 34 deletions
diff --git a/src/client/api/client.test.ts b/src/client/api/client.test.ts
index 27c3a0a..e2d314f 100644
--- a/src/client/api/client.test.ts
+++ b/src/client/api/client.test.ts
@@ -119,27 +119,6 @@ describe("ApiClient", () => {
});
});
- describe("isAuthenticated", () => {
- it("returns true when tokens exist", () => {
- const mockStorage = createMockTokenStorage();
- mockStorage.getTokens.mockReturnValue({
- accessToken: "token",
- refreshToken: "refresh",
- });
- const client = new ApiClient({ tokenStorage: mockStorage });
-
- expect(client.isAuthenticated()).toBe(true);
- });
-
- it("returns false when no tokens", () => {
- const mockStorage = createMockTokenStorage();
- mockStorage.getTokens.mockReturnValue(null);
- const client = new ApiClient({ tokenStorage: mockStorage });
-
- expect(client.isAuthenticated()).toBe(false);
- });
- });
-
describe("getAuthHeader", () => {
it("returns auth header when tokens exist", () => {
const mockStorage = createMockTokenStorage();
diff --git a/src/client/api/client.ts b/src/client/api/client.ts
index fc718a2..539df8b 100644
--- a/src/client/api/client.ts
+++ b/src/client/api/client.ts
@@ -101,16 +101,22 @@ export class ApiClient {
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}`);
+ if (response.status === 401 && tokens?.accessToken) {
+ if (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 fetch(input, { ...init, headers });
+ } else {
+ // No refresh token available — treat as session expiry
+ this.tokenStorage.clearTokens();
+ this.sessionExpiredCallback?.();
}
}
@@ -205,10 +211,6 @@ export class ApiClient {
this.tokenStorage.clearTokens();
}
- isAuthenticated(): boolean {
- return this.tokenStorage.getTokens() !== null;
- }
-
getTokens(): Tokens | null {
return this.tokenStorage.getTokens();
}