aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-01-01 21:28:33 +0900
committernsfisis <nsfisis@gmail.com>2026-01-01 21:28:33 +0900
commit1d31f2ec8921bb58d74458b057bbb31f4877c335 (patch)
tree695e24534c9529d4271aa7ed544eaadb697c93b6
parentbf286c11d3244afb5132271dac656109934150e0 (diff)
downloadkioku-1d31f2ec8921bb58d74458b057bbb31f4877c335.tar.gz
kioku-1d31f2ec8921bb58d74458b057bbb31f4877c335.tar.zst
kioku-1d31f2ec8921bb58d74458b057bbb31f4877c335.zip
fix(auth): redirect to login when session expires
When the refresh token fails (session expired), the ApiClient now notifies the AuthProvider via a callback. This triggers a logout and React state update, causing ProtectedRoute to redirect to /login. Closes #7 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--src/client/App.test.tsx1
-rw-r--r--src/client/api/client.ts13
-rw-r--r--src/client/components/ProtectedRoute.test.tsx1
-rw-r--r--src/client/pages/DeckDetailPage.test.tsx1
-rw-r--r--src/client/pages/HomePage.test.tsx1
-rw-r--r--src/client/pages/LoginPage.test.tsx1
-rw-r--r--src/client/pages/NoteTypesPage.test.tsx1
-rw-r--r--src/client/pages/StudyPage.test.tsx1
-rw-r--r--src/client/stores/auth.test.tsx1
-rw-r--r--src/client/stores/auth.tsx24
10 files changed, 37 insertions, 8 deletions
diff --git a/src/client/App.test.tsx b/src/client/App.test.tsx
index fe870b7..2617f44 100644
--- a/src/client/App.test.tsx
+++ b/src/client/App.test.tsx
@@ -17,6 +17,7 @@ vi.mock("./api/client", () => ({
isAuthenticated: vi.fn(),
getTokens: vi.fn(),
getAuthHeader: vi.fn(),
+ onSessionExpired: vi.fn(() => vi.fn()),
rpc: {
api: {
decks: {
diff --git a/src/client/api/client.ts b/src/client/api/client.ts
index b39f064..c91160d 100644
--- a/src/client/api/client.ts
+++ b/src/client/api/client.ts
@@ -58,10 +58,13 @@ export function createClient(baseUrl: string): Client {
return hc<AppType>(baseUrl);
}
+export type SessionExpiredCallback = () => void;
+
export class ApiClient {
private tokenStorage: TokenStorage;
private refreshPromise: Promise<boolean> | null = null;
private baseUrl: string;
+ private sessionExpiredCallback: SessionExpiredCallback | null = null;
public readonly rpc: Client;
constructor(options: ApiClientOptions = {}) {
@@ -70,6 +73,13 @@ export class ApiClient {
this.rpc = this.createAuthenticatedClient();
}
+ onSessionExpired(callback: SessionExpiredCallback): () => void {
+ this.sessionExpiredCallback = callback;
+ return () => {
+ this.sessionExpiredCallback = null;
+ };
+ }
+
private createAuthenticatedClient(): Client {
return hc<AppType>(this.baseUrl, {
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
@@ -156,8 +166,9 @@ export class ApiClient {
});
if (!res.ok) {
- // Clear tokens if refresh fails
+ // Clear tokens if refresh fails and notify listeners
this.tokenStorage.clearTokens();
+ this.sessionExpiredCallback?.();
return false;
}
diff --git a/src/client/components/ProtectedRoute.test.tsx b/src/client/components/ProtectedRoute.test.tsx
index 85a12cd..25e73a3 100644
--- a/src/client/components/ProtectedRoute.test.tsx
+++ b/src/client/components/ProtectedRoute.test.tsx
@@ -15,6 +15,7 @@ vi.mock("../api/client", () => ({
logout: vi.fn(),
isAuthenticated: vi.fn(),
getTokens: vi.fn(),
+ onSessionExpired: vi.fn(() => vi.fn()),
},
ApiClientError: class ApiClientError extends Error {
constructor(
diff --git a/src/client/pages/DeckDetailPage.test.tsx b/src/client/pages/DeckDetailPage.test.tsx
index e02302f..1ef6ae7 100644
--- a/src/client/pages/DeckDetailPage.test.tsx
+++ b/src/client/pages/DeckDetailPage.test.tsx
@@ -17,6 +17,7 @@ vi.mock("../api/client", () => ({
isAuthenticated: vi.fn(),
getTokens: vi.fn(),
getAuthHeader: vi.fn(),
+ onSessionExpired: vi.fn(() => vi.fn()),
rpc: {
api: {
decks: {
diff --git a/src/client/pages/HomePage.test.tsx b/src/client/pages/HomePage.test.tsx
index 5b8489a..944dd31 100644
--- a/src/client/pages/HomePage.test.tsx
+++ b/src/client/pages/HomePage.test.tsx
@@ -18,6 +18,7 @@ vi.mock("../api/client", () => ({
isAuthenticated: vi.fn(),
getTokens: vi.fn(),
getAuthHeader: vi.fn(),
+ onSessionExpired: vi.fn(() => vi.fn()),
rpc: {
api: {
decks: {
diff --git a/src/client/pages/LoginPage.test.tsx b/src/client/pages/LoginPage.test.tsx
index e4dac95..a3efa8d 100644
--- a/src/client/pages/LoginPage.test.tsx
+++ b/src/client/pages/LoginPage.test.tsx
@@ -16,6 +16,7 @@ vi.mock("../api/client", () => ({
logout: vi.fn(),
isAuthenticated: vi.fn(),
getTokens: vi.fn(),
+ onSessionExpired: vi.fn(() => vi.fn()),
},
ApiClientError: class ApiClientError extends Error {
constructor(
diff --git a/src/client/pages/NoteTypesPage.test.tsx b/src/client/pages/NoteTypesPage.test.tsx
index c2df7f5..8364d17 100644
--- a/src/client/pages/NoteTypesPage.test.tsx
+++ b/src/client/pages/NoteTypesPage.test.tsx
@@ -18,6 +18,7 @@ vi.mock("../api/client", () => ({
isAuthenticated: vi.fn(),
getTokens: vi.fn(),
getAuthHeader: vi.fn(),
+ onSessionExpired: vi.fn(() => vi.fn()),
},
ApiClientError: class ApiClientError extends Error {
constructor(
diff --git a/src/client/pages/StudyPage.test.tsx b/src/client/pages/StudyPage.test.tsx
index 146322a..bc87b9d 100644
--- a/src/client/pages/StudyPage.test.tsx
+++ b/src/client/pages/StudyPage.test.tsx
@@ -17,6 +17,7 @@ vi.mock("../api/client", () => ({
isAuthenticated: vi.fn(),
getTokens: vi.fn(),
getAuthHeader: vi.fn(),
+ onSessionExpired: vi.fn(() => vi.fn()),
rpc: {
api: {
decks: {
diff --git a/src/client/stores/auth.test.tsx b/src/client/stores/auth.test.tsx
index 72ab9e3..1769011 100644
--- a/src/client/stores/auth.test.tsx
+++ b/src/client/stores/auth.test.tsx
@@ -14,6 +14,7 @@ vi.mock("../api/client", () => ({
logout: vi.fn(),
isAuthenticated: vi.fn(),
getTokens: vi.fn(),
+ onSessionExpired: vi.fn(() => vi.fn()),
},
ApiClientError: class ApiClientError extends Error {
constructor(
diff --git a/src/client/stores/auth.tsx b/src/client/stores/auth.tsx
index b34717b..3f2681b 100644
--- a/src/client/stores/auth.tsx
+++ b/src/client/stores/auth.tsx
@@ -31,6 +31,15 @@ export interface AuthProviderProps {
export function AuthProvider({ children }: AuthProviderProps) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
+ const [isAuthenticated, setIsAuthenticated] = useState(
+ apiClient.isAuthenticated(),
+ );
+
+ const logout = useCallback(() => {
+ apiClient.logout();
+ setUser(null);
+ setIsAuthenticated(false);
+ }, []);
// Check for existing auth on mount
useEffect(() => {
@@ -45,18 +54,19 @@ export function AuthProvider({ children }: AuthProviderProps) {
}
}, []);
+ // Subscribe to session expired events from the API client
+ useEffect(() => {
+ return apiClient.onSessionExpired(() => {
+ logout();
+ });
+ }, [logout]);
+
const login = useCallback(async (username: string, password: string) => {
const response = await apiClient.login(username, password);
setUser(response.user);
+ setIsAuthenticated(true);
}, []);
- const logout = useCallback(() => {
- apiClient.logout();
- setUser(null);
- }, []);
-
- const isAuthenticated = apiClient.isAuthenticated();
-
const value = useMemo<AuthContextValue>(
() => ({
user,